diff --git a/.github/ISSUE_TEMPLATE/python-issue.yml b/.github/ISSUE_TEMPLATE/python-issue.yml index 3a506c66fe..4c4d94a953 100644 --- a/.github/ISSUE_TEMPLATE/python-issue.yml +++ b/.github/ISSUE_TEMPLATE/python-issue.yml @@ -47,7 +47,7 @@ body: attributes: label: Package Versions description: List the agent-framework-* packages and versions you are using - placeholder: "e.g., agent-framework-core: 1.0.0, agent-framework-azure-ai: 1.0.0" + placeholder: "e.g., agent-framework-core: 1.0.0, agent-framework-foundry: 1.0.0" validations: required: true diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index a47d09ff7d..6454adba31 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -82,7 +82,7 @@ jobs: .github dotnet python - workflow-samples + declarative-agents - name: Setup dotnet uses: actions/setup-dotnet@v5.2.0 @@ -152,7 +152,7 @@ jobs: .github dotnet python - workflow-samples + declarative-agents # Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened) - name: Start Azure Cosmos DB Emulator diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 15c2a16712..3aedbacd1a 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -38,7 +38,7 @@ jobs: .github dotnet python - workflow-samples + declarative-agents - name: Start Azure Cosmos DB Emulator if: runner.os == 'Windows' diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml new file mode 100644 index 0000000000..ad384eb83e --- /dev/null +++ b/.github/workflows/dotnet-verify-samples.yml @@ -0,0 +1,122 @@ +# +# Runs the .NET sample verification tool, which builds and executes sample projects +# and verifies their output using deterministic checks and AI-powered verification. +# +# Results are displayed as a GitHub Job Summary and the CSV report is uploaded as an artifact. +# + +name: dotnet-verify-samples + +on: + workflow_dispatch: + inputs: + category: + description: "Sample category to run (blank for all)" + required: false + type: choice + options: + - "" + - "01-get-started" + - "02-agents" + - "03-workflows" + parallelism: + description: "Max parallel sample runs" + required: false + default: "8" + type: string + schedule: + - cron: "0 6 * * 1-5" # Weekdays at 6:00 UTC + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + id-token: write + +jobs: + verify-samples: + runs-on: ubuntu-latest + environment: 'integration' + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + sparse-checkout: | + . + .github + dotnet + workflow-samples + + - name: Setup dotnet + uses: actions/setup-dotnet@v5.2.0 + with: + global-json-file: ${{ github.workspace }}/dotnet/global.json + + - name: Azure CLI Login + if: github.event_name != 'pull_request' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Run verify-samples + id: verify + working-directory: dotnet + shell: bash + run: | + CATEGORY_ARG="" + if [ -n "$CATEGORY_INPUT" ]; then + CATEGORY_ARG="--category $CATEGORY_INPUT" + fi + + dotnet run --project eng/verify-samples -- \ + $CATEGORY_ARG \ + --parallel "$PARALLELISM" \ + --md results.md \ + --csv results.csv \ + --log results.log + env: + CATEGORY_INPUT: ${{ github.event.inputs.category || '' }} + PARALLELISM: ${{ github.event.inputs.parallelism || '8' }} + # 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 }} + # Azure AI 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: Write Job Summary + if: always() + working-directory: dotnet + shell: bash + run: | + if [ -f results.md ]; then + cat results.md >> "$GITHUB_STEP_SUMMARY" + else + echo "⚠️ No results.md generated — verify-samples may have failed to start." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: verify-samples-results + path: | + dotnet/results.csv + dotnet/results.log + if-no-files-found: warn + + - name: Fail if samples failed + if: always() && steps.verify.outcome == 'failure' + shell: bash + run: exit 1 diff --git a/.github/workflows/python-check-coverage.py b/.github/workflows/python-check-coverage.py index af6d38ffea..c9694aa35e 100644 --- a/.github/workflows/python-check-coverage.py +++ b/.github/workflows/python-check-coverage.py @@ -34,14 +34,14 @@ from dataclasses import dataclass # (e.g., "packages/core/agent_framework/observability.py") # ============================================================================= ENFORCED_TARGETS: set[str] = { - # Packages - "packages.azure-ai.agent_framework_azure_ai", - "packages.core.agent_framework", - "packages.core.agent_framework._workflows", - "packages.purview.agent_framework_purview", + # Packages (sorted alphabetically) "packages.anthropic.agent_framework_anthropic", "packages.azure-ai-search.agent_framework_azure_ai_search", + "packages.core.agent_framework", + "packages.core.agent_framework._workflows", + "packages.foundry.agent_framework_foundry", "packages.openai.agent_framework_openai", + "packages.purview.agent_framework_purview", # Individual files (if you want to enforce specific files instead of whole packages) "packages/core/agent_framework/observability.py", # Add more targets here as coverage improves diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index df2fda5cb2..523a763b62 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -60,8 +60,8 @@ jobs: environment: integration timeout-minutes: 60 env: - OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + 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 }} @@ -95,10 +95,10 @@ jobs: environment: integration timeout-minutes: 60 env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} + AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -126,8 +126,6 @@ jobs: packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py packages/openai/tests/openai/test_openai_embedding_client_azure.py - packages/azure-ai/tests/azure_openai - --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -141,7 +139,7 @@ jobs: timeout-minutes: 60 env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} + ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -203,14 +201,15 @@ jobs: timeout-minutes: 60 env: UV_PYTHON: "3.11" - OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + 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_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + 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" @@ -257,12 +256,14 @@ jobs: environment: integration timeout-minutes: 60 env: - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} + FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }} + FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }} + FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }} + FOUNDRY_IMAGE_EMBEDDING_MODEL: ${{ vars.FOUNDRY_IMAGE_EMBEDDING_MODEL || '' }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -288,7 +289,6 @@ jobs: timeout-minutes: 15 run: > uv run pytest --import-mode=importlib - packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py packages/foundry/tests -m integration -n logical --dist worksteal diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 453e4335a6..4417165a24 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -37,7 +37,7 @@ jobs: azureChanged: ${{ steps.filter.outputs.azure }} miscChanged: ${{ steps.filter.outputs.misc }} functionsChanged: ${{ steps.filter.outputs.functions }} - azureAiChanged: ${{ steps.filter.outputs.azure-ai }} + foundryChanged: ${{ steps.filter.outputs.foundry }} cosmosChanged: ${{ steps.filter.outputs.cosmos }} steps: - uses: actions/checkout@v6 @@ -62,9 +62,7 @@ jobs: azure: - 'python/packages/openai/**' - 'python/packages/core/agent_framework/azure/**' - - 'python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py' - - 'python/packages/azure-ai/tests/azure_openai/**' - - 'python/samples/**/providers/azure/openai_chat_completion_client_azure*.py' + - 'python/samples/**/providers/azure/**' misc: - 'python/packages/anthropic/**' - 'python/packages/ollama/**' @@ -77,10 +75,10 @@ jobs: functions: - 'python/packages/azurefunctions/**' - 'python/packages/durabletask/**' - azure-ai: - - 'python/packages/azure-ai/**' + foundry: - 'python/packages/foundry/**' - 'python/samples/**/providers/foundry/**' + - 'python/samples/02-agents/embeddings/foundry_embeddings.py' cosmos: - 'python/packages/azure-cosmos/**' # run only if 'python' files were changed @@ -141,8 +139,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + 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 }} @@ -194,10 +192,10 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} + AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -223,8 +221,6 @@ jobs: packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py packages/openai/tests/openai/test_openai_embedding_client_azure.py - packages/azure-ai/tests/azure_openai - --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -259,7 +255,7 @@ jobs: environment: integration env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} + ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -334,14 +330,15 @@ jobs: environment: integration env: UV_PYTHON: "3.11" - OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + 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_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + 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" @@ -396,13 +393,11 @@ jobs: github.event_name != 'pull_request' && needs.paths-filter.outputs.pythonChanges == 'true' && (github.event_name != 'merge_group' || - needs.paths-filter.outputs.azureAiChanged == 'true' || + needs.paths-filter.outputs.foundryChanged == 'true' || needs.paths-filter.outputs.coreChanged == 'true') runs-on: ubuntu-latest environment: integration env: - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} @@ -430,18 +425,12 @@ jobs: timeout-minutes: 15 run: > uv run pytest --import-mode=importlib - packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py packages/foundry/tests -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 working-directory: ./python - - name: Test Azure AI samples - timeout-minutes: 10 - if: env.RUN_SAMPLES_TESTS == 'true' - run: uv run pytest tests/samples/ -m "azure-ai" - working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 diff --git a/.github/workflows/python-sample-validation.yml b/.github/workflows/python-sample-validation.yml index 7ce2219573..8b72df3b74 100644 --- a/.github/workflows/python-sample-validation.yml +++ b/.github/workflows/python-sample-validation.yml @@ -23,10 +23,8 @@ jobs: environment: integration env: # Required configuration for get-started samples - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python @@ -43,10 +41,8 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env + echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env - name: Run sample validation run: | @@ -64,20 +60,19 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + # Foundry configuration + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} # GitHub MCP GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} @@ -101,15 +96,14 @@ jobs: run: | echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=$AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env + echo "AZURE_OPENAI_CHAT_COMPLETION_MODEL=$AZURE_OPENAI_CHAT_COMPLETION_MODEL" >> .env + echo "AZURE_OPENAI_CHAT_MODEL=$AZURE_OPENAI_CHAT_MODEL" >> .env + echo "AZURE_OPENAI_EMBEDDING_MODEL=$AZURE_OPENAI_EMBEDDING_MODEL" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_COMPLETION_MODEL=$OPENAI_CHAT_COMPLETION_MODEL" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env echo "GITHUB_PAT=$GITHUB_PAT" >> .env - name: Run sample validation @@ -130,8 +124,8 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} defaults: run: working-directory: python @@ -150,8 +144,8 @@ jobs: run: | echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env echo "OPENAI_MODEL=$OPENAI_MODEL" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_COMPLETION_MODEL=$OPENAI_CHAT_COMPLETION_MODEL" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env - name: Run sample validation run: | @@ -169,10 +163,9 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION || '' }} defaults: run: working-directory: python @@ -189,10 +182,9 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env + echo "AZURE_OPENAI_API_VERSION=$AZURE_OPENAI_API_VERSION" >> .env - name: Run sample validation run: | @@ -211,7 +203,7 @@ jobs: environment: integration env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} + ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} defaults: run: working-directory: python @@ -229,7 +221,7 @@ jobs: - name: Create .env for samples run: | echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> .env - echo "ANTHROPIC_CHAT_MODEL_ID=$ANTHROPIC_CHAT_MODEL_ID" >> .env + echo "ANTHROPIC_CHAT_MODEL=$ANTHROPIC_CHAT_MODEL" >> .env - name: Run sample validation run: | @@ -277,7 +269,7 @@ jobs: runs-on: ubuntu-latest environment: integration env: - BEDROCK_CHAT_MODEL_ID: ${{ vars.BEDROCK__CHATMODELID }} + BEDROCK_CHAT_MODEL: ${{ vars.BEDROCK__CHATMODELID }} defaults: run: working-directory: python @@ -337,11 +329,14 @@ jobs: validate-02-agents-foundry: name: Validate 02-agents/providers/foundry + if: false # Temporarily disabled - provider folder also contains the local Foundry sample runs-on: ubuntu-latest environment: integration env: - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME || '' }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION || '' }} defaults: run: working-directory: python @@ -360,6 +355,8 @@ jobs: run: | echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env + echo "FOUNDRY_AGENT_NAME=$FOUNDRY_AGENT_NAME" >> .env + echo "FOUNDRY_AGENT_VERSION=$FOUNDRY_AGENT_VERSION" >> .env - name: Run sample validation run: | @@ -448,15 +445,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python @@ -475,11 +465,6 @@ jobs: run: | echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env - name: Run sample validation run: | @@ -498,12 +483,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # A2A configuration A2A_AGENT_HOST: http://localhost:5001/ defaults: @@ -537,19 +518,18 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure AI Search (for evaluation samples) AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }} AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }} AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }} # Evaluation sample - AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_MODEL_WORKFLOW: ${{ vars.FOUNDRY_MODEL_WORKFLOW || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_MODEL_EVAL: ${{ vars.FOUNDRY_MODEL_EVAL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python @@ -580,16 +560,15 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} defaults: run: @@ -607,13 +586,13 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env + echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_COMPLETION_MODEL=$OPENAI_CHAT_COMPLETION_MODEL" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env - name: Run sample validation run: | @@ -631,18 +610,20 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - # Azure OpenAI configuration + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + # Azure OpenAI configuration for AF AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - # OpenAI configuration + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + # Azure OpenAI configuration for SK + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }} + # OpenAI key OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + # OpenAI configuration for SK + OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} # Copilot Studio COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }} COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }} @@ -664,14 +645,13 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env + echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_COMPLETION_MODEL=$OPENAI_CHAT_COMPLETION_MODEL" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env echo "COPILOTSTUDIOAGENT__ENVIRONMENTID=$COPILOTSTUDIOAGENT__ENVIRONMENTID" >> .env echo "COPILOTSTUDIOAGENT__SCHEMANAME=$COPILOTSTUDIOAGENT__SCHEMANAME" >> .env echo "COPILOTSTUDIOAGENT__TENANTID=$COPILOTSTUDIOAGENT__TENANTID" >> .env diff --git a/.gitignore b/.gitignore index 4dd5848e89..089abb5395 100644 --- a/.gitignore +++ b/.gitignore @@ -230,3 +230,6 @@ local.settings.json # Database files *.db python/dotnet-ref + +# Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1) +dotnet/filtered-*.slnx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4999145e81..12318d3f91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,22 +123,30 @@ We use and recommend the following workflow: "issue-123" or "githubhandle-issue". 4. Make and commit your changes to your branch. 5. Add new tests corresponding to your change, if applicable. -6. Run the relevant scripts in [the section below](#development-scripts) to ensure that your build is clean and all tests are passing. +6. Run the relevant scripts in [the section below](#development-setup) to ensure that your build is clean and all tests are passing. 7. Create a PR against the repository's **main** branch. - State in the description what issue or improvement your change is addressing. - Verify that all the Continuous Integration checks are passing. 8. Wait for feedback or approval of your changes from the code maintainers. 9. When area owners have signed off, and all checks are green, your PR will be merged. -### Development scripts +### Development Setup -The scripts below are used to build, test, and lint within the project. +Each language has its own dev setup guide, coding standards, and build scripts: -- Python: see [python/DEV_SETUP.md](./python/DEV_SETUP.md). -- .NET: - - Build: `dotnet build` - - Test: `dotnet test` - - Linting (auto-fix): `dotnet format` +- **Python**: [Dev Setup](./python/DEV_SETUP.md) · [Coding Standard](./python/CODING_STANDARD.md) · [README](./python/README.md) + - From the `./python` directory: + - Build: `uv run poe build` + - Unit tests: `uv run poe test -A -m "not integration"` + - Integration tests: `uv run poe test -A -m integration` (requires API keys/endpoints) + - Format + lint: `uv run poe syntax` + - All checks: `uv run poe check` +- **.NET**: [README](./dotnet/README.md) · [Agent Instructions](./dotnet/AGENTS.md) + - From the `./dotnet` directory: + - Build: `dotnet build` + - Unit tests: `dotnet test --filter-query "/*UnitTests*/*/*/*"` + - Integration tests: `dotnet test --filter-query "/*IntegrationTests*/*/*/*"` (requires API keys/endpoints) + - Linting (auto-fix): `dotnet format` ### PR - CI Process diff --git a/README.md b/README.md index 1c41003080..4d5a9a30fc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Welcome to Microsoft Agent Framework! -[![Microsoft Azure AI Foundry Discord](https://dcbadge.limes.pink/api/server/b5zjErwbQM?style=flat)](https://discord.gg/b5zjErwbQM) +[![Microsoft Foundry Discord](https://dcbadge.limes.pink/api/server/b5zjErwbQM?style=flat)](https://discord.gg/b5zjErwbQM) [![MS Learn Documentation](https://img.shields.io/badge/MS%20Learn-Documentation-blue)](https://learn.microsoft.com/en-us/agent-framework/) [![PyPI](https://img.shields.io/pypi/v/agent-framework)](https://pypi.org/project/agent-framework/) [![NuGet](https://img.shields.io/nuget/v/Microsoft.Agents.AI)](https://www.nuget.org/profiles/MicrosoftAgentFramework/) @@ -28,7 +28,7 @@ Welcome to Microsoft's comprehensive multi-language framework for building, orch Python ```bash -pip install agent-framework --pre +pip install agent-framework # This will install all sub-packages, see `python/packages` for individual packages. # It may take a minute on first install on Windows. ``` @@ -90,27 +90,27 @@ Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-commu Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework ```python -# pip install agent-framework --pre +# pip install agent-framework # Use `az login` to authenticate with Azure CLI import os import asyncio -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential async def main(): - # Initialize a chat agent with Azure OpenAI Responses + # Initialize a chat agent with Microsoft Foundry # the endpoint, deployment name, and api version can be set via environment variables - # or they can be passed in directly to the AzureOpenAIResponsesClient constructor - agent = AzureOpenAIResponsesClient( - # endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - # deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - # api_version=os.environ["AZURE_OPENAI_API_VERSION"], - # api_key=os.environ["AZURE_OPENAI_API_KEY"], # Optional if using AzureCliCredential - credential=AzureCliCredential(), # Optional, if using api_key - ).as_agent( - name="HaikuBot", - instructions="You are an upbeat assistant that writes beautifully.", + # or they can be passed in directly to the FoundryChatClient constructor + agent = Agent( + client=FoundryChatClient( + credential=AzureCliCredential(), + # project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + # model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"], + ), + name="HaikuBot", + instructions="You are an upbeat assistant that writes beautifully.", ) print(await agent.run("Write a haiku about Microsoft Agent Framework.")) @@ -137,24 +137,21 @@ var agent = new OpenAIClient("") Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); ``` -Create a simple Agent, using Azure OpenAI Responses with token based auth, that writes a haiku about the Microsoft Agent Framework +Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework ```c# -// dotnet add package Microsoft.Agents.AI.OpenAI --prerelease +// dotnet add package Microsoft.Agents.AI.AzureAI --prerelease // dotnet add package Azure.Identity // Use `az login` to authenticate with Azure CLI -using System.ClientModel.Primitives; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -using OpenAI; -using OpenAI.Responses; -// Replace and gpt-4o-mini with your Azure OpenAI resource name and deployment name. -var agent = new OpenAIClient( - new BearerTokenPolicy(new AzureCliCredential(), "https://ai.azure.com/.default"), - new OpenAIClientOptions() { Endpoint = new Uri("https://.openai.azure.com/openai/v1") }) - .GetResponsesClient("gpt-4o-mini") - .AsAIAgent(name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); +var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); ``` @@ -163,15 +160,43 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram ### Python -- [Getting Started with Agents](./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 hosting - [Agent Concepts](./python/samples/02-agents): deep-dive samples by topic (tools, middleware, providers, etc.) -- [Getting Started with Workflows](./python/samples/03-workflows): workflow creation and integration with agents +- [Workflows](./python/samples/03-workflows): workflow creation and integration with agents +- [Hosting](./python/samples/04-hosting): A2A, Azure Functions, Durable Task hosting +- [End-to-End](./python/samples/05-end-to-end): full applications, evaluation, and demos ### .NET -- [Getting Started with Agents](./dotnet/samples/02-agents/Agents): basic agent creation and tool usage -- [Agent Provider Samples](./dotnet/samples/02-agents/AgentProviders): samples showing different agent providers -- [Workflow Samples](./dotnet/samples/03-workflows): advanced multi-agent patterns and workflow orchestration +- [Getting Started](./dotnet/samples/01-get-started): progressive tutorial from hello agent to hosting +- [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 +- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos + +## Troubleshooting + +### Authentication + +| Problem | Cause | Fix | +|---------|-------|-----| +| Authentication errors when using Azure credentials | Not signed in to Azure CLI | Run `az login` before starting your app | +| API key errors | Wrong or missing API key | Verify the key and ensure it's for the correct resource/provider | + +> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + +### Environment Variables + +The samples typically read configuration from environment variables. Common required variables: + +| Variable | Used by | Purpose | +|----------|---------|---------| +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) | +| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name | +| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key | ## Contributor Resources diff --git a/agent-samples/README.md b/declarative-agents/agent-samples/README.md similarity index 66% rename from agent-samples/README.md rename to declarative-agents/agent-samples/README.md index 953affeb08..751da7c045 100644 --- a/agent-samples/README.md +++ b/declarative-agents/agent-samples/README.md @@ -1,3 +1,3 @@ # Declarative Agents -This folder contains sample agent definitions that can be run using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/02-agents/declarative/). +This folder contains sample agent definitions that can be run using the declarative agent support, for python see the [declarative agent python sample folder](../../python/samples/02-agents/declarative/). diff --git a/agent-samples/azure/AzureOpenAI.yaml b/declarative-agents/agent-samples/azure/AzureOpenAI.yaml similarity index 100% rename from agent-samples/azure/AzureOpenAI.yaml rename to declarative-agents/agent-samples/azure/AzureOpenAI.yaml diff --git a/agent-samples/azure/AzureOpenAIAssistants.yaml b/declarative-agents/agent-samples/azure/AzureOpenAIAssistants.yaml similarity index 100% rename from agent-samples/azure/AzureOpenAIAssistants.yaml rename to declarative-agents/agent-samples/azure/AzureOpenAIAssistants.yaml diff --git a/agent-samples/azure/AzureOpenAIChat.yaml b/declarative-agents/agent-samples/azure/AzureOpenAIChat.yaml similarity index 100% rename from agent-samples/azure/AzureOpenAIChat.yaml rename to declarative-agents/agent-samples/azure/AzureOpenAIChat.yaml diff --git a/agent-samples/azure/AzureOpenAIResponses.yaml b/declarative-agents/agent-samples/azure/AzureOpenAIResponses.yaml similarity index 100% rename from agent-samples/azure/AzureOpenAIResponses.yaml rename to declarative-agents/agent-samples/azure/AzureOpenAIResponses.yaml diff --git a/agent-samples/chatclient/Assistant.yaml b/declarative-agents/agent-samples/chatclient/Assistant.yaml similarity index 100% rename from agent-samples/chatclient/Assistant.yaml rename to declarative-agents/agent-samples/chatclient/Assistant.yaml diff --git a/agent-samples/chatclient/GetWeather.yaml b/declarative-agents/agent-samples/chatclient/GetWeather.yaml similarity index 100% rename from agent-samples/chatclient/GetWeather.yaml rename to declarative-agents/agent-samples/chatclient/GetWeather.yaml diff --git a/agent-samples/foundry/FoundryAgent.yaml b/declarative-agents/agent-samples/foundry/FoundryAgent.yaml similarity index 100% rename from agent-samples/foundry/FoundryAgent.yaml rename to declarative-agents/agent-samples/foundry/FoundryAgent.yaml diff --git a/agent-samples/foundry/MicrosoftLearnAgent.yaml b/declarative-agents/agent-samples/foundry/MicrosoftLearnAgent.yaml similarity index 83% rename from agent-samples/foundry/MicrosoftLearnAgent.yaml rename to declarative-agents/agent-samples/foundry/MicrosoftLearnAgent.yaml index 8e15340351..af20bbf18b 100644 --- a/agent-samples/foundry/MicrosoftLearnAgent.yaml +++ b/declarative-agents/agent-samples/foundry/MicrosoftLearnAgent.yaml @@ -3,13 +3,13 @@ name: MicrosoftLearnAgent description: Microsoft Learn Agent instructions: You answer questions by searching the Microsoft Learn content only. model: - id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID + id: =Env.FOUNDRY_MODEL options: temperature: 0.9 topP: 0.95 connection: kind: remote - endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT + endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT tools: - kind: mcp name: microsoft_learn diff --git a/agent-samples/foundry/PersistentAgent.yaml b/declarative-agents/agent-samples/foundry/PersistentAgent.yaml similarity index 100% rename from agent-samples/foundry/PersistentAgent.yaml rename to declarative-agents/agent-samples/foundry/PersistentAgent.yaml diff --git a/agent-samples/openai/OpenAI.yaml b/declarative-agents/agent-samples/openai/OpenAI.yaml similarity index 100% rename from agent-samples/openai/OpenAI.yaml rename to declarative-agents/agent-samples/openai/OpenAI.yaml diff --git a/agent-samples/openai/OpenAIAssistants.yaml b/declarative-agents/agent-samples/openai/OpenAIAssistants.yaml similarity index 100% rename from agent-samples/openai/OpenAIAssistants.yaml rename to declarative-agents/agent-samples/openai/OpenAIAssistants.yaml diff --git a/agent-samples/openai/OpenAIChat.yaml b/declarative-agents/agent-samples/openai/OpenAIChat.yaml similarity index 100% rename from agent-samples/openai/OpenAIChat.yaml rename to declarative-agents/agent-samples/openai/OpenAIChat.yaml diff --git a/agent-samples/openai/OpenAIResponses.yaml b/declarative-agents/agent-samples/openai/OpenAIResponses.yaml similarity index 100% rename from agent-samples/openai/OpenAIResponses.yaml rename to declarative-agents/agent-samples/openai/OpenAIResponses.yaml diff --git a/workflow-samples/CustomerSupport.yaml b/declarative-agents/workflow-samples/CustomerSupport.yaml similarity index 100% rename from workflow-samples/CustomerSupport.yaml rename to declarative-agents/workflow-samples/CustomerSupport.yaml diff --git a/workflow-samples/DeepResearch.yaml b/declarative-agents/workflow-samples/DeepResearch.yaml similarity index 100% rename from workflow-samples/DeepResearch.yaml rename to declarative-agents/workflow-samples/DeepResearch.yaml diff --git a/workflow-samples/Marketing.yaml b/declarative-agents/workflow-samples/Marketing.yaml similarity index 100% rename from workflow-samples/Marketing.yaml rename to declarative-agents/workflow-samples/Marketing.yaml diff --git a/workflow-samples/MathChat.yaml b/declarative-agents/workflow-samples/MathChat.yaml similarity index 100% rename from workflow-samples/MathChat.yaml rename to declarative-agents/workflow-samples/MathChat.yaml diff --git a/workflow-samples/README.md b/declarative-agents/workflow-samples/README.md similarity index 79% rename from workflow-samples/README.md rename to declarative-agents/workflow-samples/README.md index 07cbb859e2..7bb6af1943 100644 --- a/workflow-samples/README.md +++ b/declarative-agents/workflow-samples/README.md @@ -10,8 +10,8 @@ Workflow workflow = DeclarativeWorkflowBuilder.Build("Marketing.yaml", options); ``` These example workflows may be executed by the workflow -[Samples](../dotnet/samples/03-workflows/Declarative) +[Samples](../../dotnet/samples/03-workflows/Declarative) that are present in this repository. -> See the [README.md](../dotnet/samples/03-workflows/Declarative/README.md) +> See the [README.md](../../dotnet/samples/03-workflows/Declarative/README.md) associated with the samples for configuration details. diff --git a/docs/decisions/0021-provider-leading-clients.md b/docs/decisions/0021-provider-leading-clients.md index 1dcc334209..7f95802161 100644 --- a/docs/decisions/0021-provider-leading-clients.md +++ b/docs/decisions/0021-provider-leading-clients.md @@ -37,7 +37,7 @@ Key changes: 4. **New `FoundryChatClient`** in azure-ai for Azure AI Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`. 5. **All deprecated `AzureOpenAI*` classes** consolidated into a single file (`_deprecated_azure_openai.py`) in the azure-ai package for clean future deletion. 6. **Core's `agent_framework.openai` and `agent_framework.azure` namespaces** become lazy-loading gateways, preserving backward-compatible import paths while removing hard dependencies. -7. **Unified `model` parameter** replaces `model_id` (OpenAI), `deployment_name` (Azure OpenAI), and `model_deployment_name` (Azure AI) across all client constructors. The term `model` is intentionally generic: it naturally maps to an OpenAI model name *and* to an Azure OpenAI deployment name, making it straightforward to use `OpenAIChatClient` with either OpenAI or Azure OpenAI backends (via `AsyncAzureOpenAI`). Environment variables are similarly unified (e.g., `OPENAI_MODEL` instead of separate `OPENAI_RESPONSES_MODEL_ID` / `OPENAI_CHAT_MODEL_ID`). +7. **Unified `model` parameter** replaces `model_id` (OpenAI), `deployment_name` (Azure OpenAI), and `model_deployment_name` (Azure AI) across all client constructors. The term `model` is intentionally generic: it naturally maps to an OpenAI model name *and* to an Azure OpenAI deployment name, making it straightforward to use `OpenAIChatClient` with either OpenAI or Azure OpenAI backends (via `AsyncAzureOpenAI`). Environment variables are similarly unified (e.g., `OPENAI_MODEL` instead of separate `OPENAI_CHAT_MODEL_ID` / `OPENAI_CHAT_COMPLETION_MODEL_ID`). 8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Azure AI Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale. ### Foundry Agent Design: `FoundryAgentClient` vs `FoundryAgent` diff --git a/docs/decisions/0022-chat-history-persistence-consistency.md b/docs/decisions/0022-chat-history-persistence-consistency.md index 2784934817..5bd303a029 100644 --- a/docs/decisions/0022-chat-history-persistence-consistency.md +++ b/docs/decisions/0022-chat-history-persistence-consistency.md @@ -42,7 +42,7 @@ The persistence timing and `FunctionResultContent` trimming behaviors are interr ## Considered Options - Option 1: Per-run persistence with opt-in FRC (FunctionResultContent) trimming -- Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`) +- Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`) ## Pros and Cons of the Options @@ -57,12 +57,12 @@ Keep the current default behavior of persisting chat history only at the end of - Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C. - Bad, because this option alone does not provide a way for users to opt into per-service-call persistence, not satisfying driver E. -### Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`) +### Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`) -Introduce an optional SimulateServiceStoredChatHistory setting to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). +Introduce an optional RequirePerServiceCallChatHistoryPersistence setting to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). Settings: -- `SimulateServiceStoredChatHistory` = `true` +- `RequirePerServiceCallChatHistoryPersistence` = `true` - Good, because the stored history matches the service's behavior when opting in for both timing and content, fully satisfying driver A. - Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C. @@ -73,36 +73,49 @@ Settings: ## Decision Outcome -Chosen option: **Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`)**. The existing per-run persistence behavior is retained as-is, requiring no changes from users. Per-service-call persistence is available as an opt-in feature via the `SimulateServiceStoredChatHistory` setting. This satisfies drivers B (atomicity) and D (simplicity) for the common case, while fully satisfying driver A (consistency) for users who opt into simulated service-stored behavior. Users who need per-service-call persistence for recoverability (driver C) can enable it explicitly. +Chosen option: **Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)**. The existing per-run persistence behavior is retained as-is, requiring no changes from users. Per-service-call persistence is available as an opt-in feature via the `RequirePerServiceCallChatHistoryPersistence` setting. This satisfies drivers B (atomicity) and D (simplicity) for the common case, while fully satisfying driver A (consistency) for users who opt into simulated service-stored behavior. Users who need per-service-call persistence for recoverability (driver C) can enable it explicitly. ### Configuration Matrix -The behavior depends on the combination of `UseProvidedChatClientAsIs` and `SimulateServiceStoredChatHistory`: +The behavior depends on the combination of `UseProvidedChatClientAsIs` and `RequirePerServiceCallChatHistoryPersistence`: -| `UseProvidedChatClientAsIs` | `SimulateServiceStoredChatHistory` | Behavior | +| `UseProvidedChatClientAsIs` | `RequirePerServiceCallChatHistoryPersistence` | Behavior | |---|---|---| | `false` (default) | `false` (default) | **Per-run persistence.** Messages are persisted at the end of the full agent run via the `ChatHistoryProvider`. | -| `false` | `true` | **Per-service-call persistence (simulated).** A `ServiceStoredSimulatingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. A sentinel `ConversationId` causes FIC to treat the conversation as service-managed. | +| `false` | `true` | **Per-service-call persistence (simulated).** A `PerServiceCallChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. A sentinel `ConversationId` causes FIC to treat the conversation as service-managed. | | `true` | `false` | **Per-run persistence.** No middleware is injected because the user has provided a custom chat client stack. Messages are persisted at the end of the run. | -| `true` | `true` | **User responsibility.** The system checks whether the custom chat client stack includes a `ServiceStoredSimulatingChatClient`. If not, a warning is emitted — the user is expected to have added their own per-service-call persistence mechanism. End-of-run persistence is skipped. | +| `true` | `true` | **User responsibility.** The system checks whether the custom chat client stack includes a `PerServiceCallChatHistoryPersistingChatClient`. If not, a warning is emitted — the user is expected to have added their own per-service-call persistence mechanism. End-of-run persistence is skipped. | ### Consequences - Good, because per-run persistence is atomic by default — chat history is only updated when the full run succeeds, satisfying driver B. - Good, because the default mental model is simple: one run = one history update, satisfying driver D. -- Good, because users who opt into `SimulateServiceStoredChatHistory` get stored history that matches the service's behavior for both timing and content, fully satisfying driver A. +- Good, because users who opt into `RequirePerServiceCallChatHistoryPersistence` get stored history that matches the service's behavior for both timing and content, fully satisfying driver A. - Good, because per-service-call persistence preserves intermediate progress if the process is interrupted, satisfying driver C when opted in. - Good, because no separate `FunctionResultContent` trimming logic is needed when per-service-call persistence is active — it is naturally handled. - Good, because conflict detection (configurable via `ThrowOnChatHistoryProviderConflict`, `WarnOnChatHistoryProviderConflict`, `ClearOnChatHistoryProviderConflict`) prevents misconfiguration when a service returns a `ConversationId` alongside a configured `ChatHistoryProvider`. - Bad, because per-service-call persistence (when opted in) may leave chat history in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases. -- Neutral, because users who want per-service-call consistency can opt in via `SimulateServiceStoredChatHistory = true`, satisfying driver E. +- Neutral, because users who want per-service-call consistency can opt in via `RequirePerServiceCallChatHistoryPersistence = true`, satisfying driver E. - Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator. ### Implementation Notes #### Conversation ID Consistency -We should introduce a separate `ConversationIdPersistingChatClient`, middleware which allows us to -persist response `ConversationIds` during the FICC loop. This could be used with or without -`ServiceStoredSimulatingChatClient`. +When `RequirePerServiceCallChatHistoryPersistence` is enabled, the `PerServiceCallChatHistoryPersistingChatClient` +decorator also updates `session.ConversationId` after each service call. This handles two scenarios: + +1. **Framework-managed chat history** — the decorator sets a sentinel `ConversationId` on the response + so that `FunctionInvokingChatClient` treats the conversation as service-managed (clearing accumulated + history between iterations and not injecting duplicate `FunctionCallContent` during approval processing). + +2. **Service-stored chat history** — when the service returns a real `ConversationId`, the decorator + updates `session.ConversationId` immediately after each service call, rather than deferring the update + to the end of the run. This ensures intermediate ConversationId changes are captured even if the + process is interrupted mid-loop. + +For some service-stored scenarios (e.g., the Conversations API with the Responses API), there is only +one thread with one ID, so every service call returns the same ConversationId and this per-call update +makes no practical difference. Enabling `RequirePerServiceCallChatHistoryPersistence` ensures consistent +per-service-call behavior across all service types regardless of how they manage ConversationIds. diff --git a/docs/features/vector-stores-and-embeddings/README.md b/docs/features/vector-stores-and-embeddings/README.md index 560fdd86d6..9f820ad7c7 100644 --- a/docs/features/vector-stores-and-embeddings/README.md +++ b/docs/features/vector-stores-and-embeddings/README.md @@ -177,7 +177,7 @@ This feature ports the vector store abstractions, embedding generator abstractio **Goal:** Add embedding generators to all existing AF provider packages that have chat clients. **Mergeable:** Yes — each is independent, added to existing provider packages. -#### 2.1 — Azure AI Inference embedding (in `packages/azure-ai/`) +#### 2.1 — Foundry inference embedding (in `packages/foundry/`) #### 2.2 — Ollama embedding (in `packages/ollama/`) #### 2.3 — Anthropic embedding (in `packages/anthropic/`) #### 2.4 — Bedrock embedding (in `packages/bedrock/`) diff --git a/dotnet/.github/skills/project-structure/SKILL.md b/dotnet/.github/skills/project-structure/SKILL.md index 01dcafabf8..6ec9476039 100644 --- a/dotnet/.github/skills/project-structure/SKILL.md +++ b/dotnet/.github/skills/project-structure/SKILL.md @@ -12,8 +12,8 @@ dotnet/ │ ├── Microsoft.Agents.AI.Abstractions/ # Core AI agent abstractions │ ├── Microsoft.Agents.AI.A2A/ # Agent-to-Agent (A2A) provider │ ├── Microsoft.Agents.AI.OpenAI/ # OpenAI provider -│ ├── Microsoft.Agents.AI.AzureAI/ # Azure AI Foundry Agents (v2) provider -│ ├── Microsoft.Agents.AI.AzureAI.Persistent/ # Legacy Azure AI Foundry Agents (v1) provider +│ ├── Microsoft.Agents.AI.Foundry/ # Microsoft Foundry Agents (v2) provider +│ ├── Microsoft.Agents.AI.AzureAI.Persistent/ # Legacy Microsoft Foundry Agents (v1) provider │ ├── Microsoft.Agents.AI.Anthropic/ # Anthropic provider │ ├── Microsoft.Agents.AI.Workflows/ # Workflow orchestration │ └── ... # Other packages diff --git a/dotnet/.github/skills/verify-samples-tool/SKILL.md b/dotnet/.github/skills/verify-samples-tool/SKILL.md new file mode 100644 index 0000000000..cbb1b35009 --- /dev/null +++ b/dotnet/.github/skills/verify-samples-tool/SKILL.md @@ -0,0 +1,214 @@ +--- +name: verify-samples-tool +description: How to use the verify-samples tool to run, verify, and manage sample definitions in the Agent Framework repository. Use this when adding, updating, or running sample verification. +--- + +# verify-samples Tool + +The `verify-samples` project (`dotnet/eng/verify-samples/`) is an automated tool that runs sample projects and verifies their output using deterministic checks and AI-powered verification. + +## Running verify-samples + +```bash +cd dotnet + +# Run all samples across all categories +dotnet run --project eng/verify-samples -- --log results.log --csv results.csv + +# Run a specific category +dotnet run --project eng/verify-samples -- --category 02-agents --log results.log + +# Run specific samples by name +dotnet run --project eng/verify-samples -- Agent_Step02_StructuredOutput Agent_Step09_AsFunctionTool + +# Control parallelism (default 8) +dotnet run --project eng/verify-samples -- --parallel 8 --log results.log + +# Combine options +dotnet run --project eng/verify-samples -- --category 03-workflows --parallel 4 --log results.log --csv results.csv --md results.md +``` + +### Required Environment Variables + +The tool itself needs: +- `AZURE_OPENAI_ENDPOINT` — for the AI verification agent +- `AZURE_OPENAI_DEPLOYMENT_NAME` (optional, defaults to `gpt-5-mini`) + +Individual samples require their own env vars (e.g., `AZURE_AI_PROJECT_ENDPOINT`). The tool automatically checks and skips samples with missing env vars. + +### Output Files + +- `--log results.log` — detailed per-sample log with stdout/stderr, AI reasoning, and a summary +- `--csv results.csv` — tabular summary with Sample, ProjectPath, Status, FailedChecks, and Failures columns +- `--md results.md` — Markdown summary with results table and collapsible failure details (suitable for GitHub PR comments) + +## Sample Categories + +Definitions are in the `dotnet/eng/verify-samples/` directory: + +| Category | Config File | Registered Key | +|----------|-------------|----------------| +| 01-get-started | `GetStartedSamples.cs` | `01-get-started` | +| 02-agents | `AgentsSamples.cs` | `02-agents` | +| 03-workflows | `WorkflowSamples.cs` | `03-workflows` | + +Categories are registered in `VerifyOptions.cs` in the `s_sampleSets` dictionary. + +## SampleDefinition Properties + +Each sample is defined as a `SampleDefinition` in the appropriate config file. Key properties: + +```csharp +new SampleDefinition +{ + // Required: Display name for the sample + Name = "Agent_Step02_StructuredOutput", + + // Required: Relative path from dotnet/ to the sample project directory + ProjectPath = "samples/02-agents/Agents/Agent_Step02_StructuredOutput", + + // Environment variables the sample requires (throws if missing) + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + + // Environment variables with defaults that would prompt on console if unset + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + + // Skip this sample with a reason (for structural issues only) + SkipReason = null, // or "Requires external service X." + + // Deterministic checks: substrings that must appear in stdout + MustContain = ["=== Section Header ==="], + + // Substrings that must NOT appear in stdout + MustNotContain = [], + + // If true, only MustContain checks are used (no AI verification) + IsDeterministic = false, + + // AI verification: natural-language descriptions of expected output + // Each entry describes one aspect to verify independently + ExpectedOutputDescription = + [ + "The output should show structured person information with Name, Age, and Occupation fields.", + "The output should not contain error messages or stack traces.", + ], + + // Stdin inputs to feed to the sample (for interactive samples) + Inputs = ["Y", "Y", "Y"], + + // Delay between stdin inputs in ms (default 2000, increase for LLM calls between inputs) + InputDelayMs = 3000, +} +``` + +## How to Add a New Sample Definition + +1. **Check the sample's Program.cs** to understand: + - What environment variables it reads (look for `GetEnvironmentVariable`) + - Whether it needs stdin input (look for `Console.ReadLine`, `Application.GetInput`) + - Whether it has an external loop (look for `EXIT` patterns in YAML workflows) + - What output it produces (section headers, markers, expected behavior) + - Whether it exits on its own or runs as a server + +2. **Choose the right verification strategy:** + - **Deterministic** (`IsDeterministic = true`): Use `MustContain` for samples with fixed output strings. No AI verification. + - **AI-verified** (default): Use `ExpectedOutputDescription` with semantic descriptions. Write expectations that are flexible enough for non-deterministic LLM output. + - **Both**: Use `MustContain` for fixed markers AND `ExpectedOutputDescription` for LLM-generated content. + +3. **Set `SkipReason` only for structural issues:** + - Web servers that don't exit + - Multi-process client/server architectures + - Samples requiring external infrastructure (MCP servers you can't reach, Docker, etc.) + - Do NOT skip for missing env vars — the tool checks those dynamically. + +4. **For interactive samples, provide `Inputs`:** + - Samples using `Application.GetInput(args)` need one initial input + - Samples with `Console.ReadLine()` approval loops need `"Y"` inputs + - YAML workflows with `externalLoop` need `"EXIT"` as the last input + - Set `InputDelayMs` to 3000-8000ms for samples with LLM calls between inputs + +5. **Add the definition** to the appropriate config file (e.g., `AgentsSamples.cs`) in the `All` list. + +6. **Register new categories** (if needed) in `VerifyOptions.cs` `s_sampleSets` dictionary. + +### Writing Good ExpectedOutputDescription + +- Write descriptions that are **semantically flexible** — LLM output varies between runs +- Each array entry should describe **one independent aspect** to verify +- Always include `"The output should not contain error messages or stack traces."` as the last entry +- Avoid exact wording expectations — use "should mention", "should contain information about", "should show" +- Bad: `"The output should say 'The weather in Amsterdam is cloudy with a high of 15°C'"` +- Good: `"The output should contain weather information about Amsterdam mentioning cloudy weather with a high of 15°C."` + +### Example: Simple LLM Sample + +```csharp +new SampleDefinition +{ + Name = "Agent_With_AzureOpenAIChatCompletion", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], +}, +``` + +### Example: Deterministic Sample + +```csharp +new SampleDefinition +{ + Name = "Workflow_Declarative_GenerateCode", + ProjectPath = "samples/03-workflows/Declarative/GenerateCode", + IsDeterministic = true, + MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"], + ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."], +}, +``` + +### Example: Interactive Sample with Approval Loop + +```csharp +new SampleDefinition +{ + Name = "FoundryAgent_Hosted_MCP", + ProjectPath = "samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y", "Y", "Y"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should show an agent using the Microsoft Learn MCP tool with approval prompts."], +}, +``` + +### Example: Declarative Workflow with External Loop + +```csharp +new SampleDefinition +{ + Name = "Workflow_Declarative_FunctionTools", + ProjectPath = "samples/03-workflows/Declarative/FunctionTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What are today's specials?", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow calling function tools to answer a question about restaurant specials."], +}, +``` + +### Example: Skipped Sample + +```csharp +new SampleDefinition +{ + Name = "Agent_MCP_Server", + ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", +}, +``` diff --git a/dotnet/AGENTS.md b/dotnet/AGENTS.md index 4cb4b67e5f..965dd9f035 100644 --- a/dotnet/AGENTS.md +++ b/dotnet/AGENTS.md @@ -29,13 +29,14 @@ using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, `AIFunct ## Key Conventions -- **Encoding**: All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly. +- **Command output capture**: When running `dotnet build`, `dotnet test`, `dotnet format`, or similar commands, redirect output to a temp file first (e.g., `dotnet build --tl:off 2>&1 | Out-File $env:TEMP\build.log`), then analyze the file as needed. This avoids re-running expensive commands when the initial analysis misses something. +- **Encoding**: All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly. When using PowerShell `Set-Content`, always pass `-Encoding UTF8BOM` to preserve the BOM (e.g., `Set-Content $file $content -NoNewline -Encoding UTF8BOM`). - **Copyright header**: `// Copyright (c) Microsoft. All rights reserved.` at top of all `.cs` files - **XML docs**: Required for all public methods and classes - **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask` - **Private classes**: Should be `sealed` unless subclassed - **Config**: Read from environment variables with `UPPER_SNAKE_CASE` naming -- **Tests**: Add Arrange/Act/Assert comments; use Moq for mocking +- **Tests**: Add Arrange/Act/Assert comments; use Moq for mocking; test methods returning `Task`/`ValueTask` must use the `Async` suffix. ## Key Design Principles diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 16b69a355f..6b73159828 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -17,7 +17,7 @@ false - false + false diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 66149edb1a..fe8252a337 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -11,7 +11,7 @@ - + @@ -22,13 +22,13 @@ - + - + - + @@ -38,7 +38,7 @@ - + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 978cb22ad0..52472009de 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -11,6 +11,7 @@ + @@ -37,7 +38,6 @@ - @@ -116,6 +116,9 @@ + + + @@ -181,6 +184,7 @@ + @@ -222,12 +226,12 @@ - - - - - - + + + + + + @@ -486,13 +490,13 @@ - + - + @@ -503,7 +507,7 @@ - + @@ -514,11 +518,11 @@ - + - + @@ -535,12 +539,12 @@ - + - + diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index d2330caae1..466df77198 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -8,13 +8,13 @@ "src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj", "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj", "src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj", - "src\\Microsoft.Agents.AI.AzureAI\\Microsoft.Agents.AI.AzureAI.csproj", + "src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj", "src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj", "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.FoundryMemory\\Microsoft.Agents.AI.FoundryMemory.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", @@ -24,7 +24,7 @@ "src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj", "src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj", "src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj", - "src\\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj", + "src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj", "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj", "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", diff --git a/dotnet/eng/verify-samples/AgentsSamples.cs b/dotnet/eng/verify-samples/AgentsSamples.cs new file mode 100644 index 0000000000..f44dd2f1f6 --- /dev/null +++ b/dotnet/eng/verify-samples/AgentsSamples.cs @@ -0,0 +1,1239 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Defines the expected behavior for each sample in 02-agents. +/// +internal static class AgentsSamples +{ + public static IReadOnlyList All { get; } = + [ + // ── AgentProviders ────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Agent_With_CustomImplementation", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_CustomImplementation", + RequiredEnvironmentVariables = [], + ExpectedOutputDescription = + [ + "The output should contain uppercased text, because the custom agent converts all text to uppercase.", + "There should be two outputs — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureOpenAIChatCompletion", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureOpenAIResponses", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain two separate joke responses about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureAIAgentsPersistent", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureAIProject", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureAIProject", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = ["Latest agent version id:"], + ExpectedOutputDescription = + [ + "The output should show a 'Latest agent version id:' line, then joke responses from the agent.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureFoundryModel", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_API_KEY", "AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── Agents ────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Agent_Step01_UsingFunctionToolsWithApprovals", + ProjectPath = "samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["Tell me a joke about a pirate", ""], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should show the agent responding to user input. The response may be about any topic — jokes, weather, or tool call results are all acceptable.", + "The output should not contain unhandled exception stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step02_StructuredOutput", + ProjectPath = "samples/02-agents/Agents/Agent_Step02_StructuredOutput", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = + [ + "=== Structured Output with ResponseFormat ===", + "Assistant Output (JSON):", + "Assistant Output (Deserialized):", + "=== Structured Output with RunAsync ===", + "=== Structured Output with RunStreamingAsync ===", + "=== Structured Output with UseStructuredOutput Middleware ===", + "Name:", + ], + ExpectedOutputDescription = + [ + "The output should have four clearly separated sections for different structured output approaches.", + "The first section should include raw JSON output and then deserialized fields including 'Name:' with a city name.", + "Each subsequent section should also show 'Name:' followed by a city name.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step03_PersistedConversations", + ProjectPath = "samples/02-agents/Agents/Agent_Step03_PersistedConversations", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["--- Serialized session ---"], + ExpectedOutputDescription = + [ + "The output should start with a joke about a pirate.", + "After the joke there should be a '--- Serialized session ---' separator followed by a JSON block representing the serialized session state.", + "After the JSON block there should be a second response that retells the same joke in a pirate voice with emojis, demonstrating that context was preserved across serialization.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step04_3rdPartyChatHistoryStorage", + ProjectPath = "samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["--- Serialized session ---"], + ExpectedOutputDescription = + [ + "The output should contain a pirate joke response and a '--- Serialized session ---' separator with session JSON.", + "It should show that the session was stored in a vector store, with a 'Session is stored in vector store under key:' line.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step06_DependencyInjection", + ProjectPath = "samples/02-agents/Agents/Agent_Step06_DependencyInjection", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["Tell me a joke about a pirate", ""], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate in response to the user's request.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step08_UsingImages", + ProjectPath = "samples/02-agents/Agents/Agent_Step08_UsingImages", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should describe an image of a nature boardwalk/walkway scene.", + "It should mention elements like a wooden boardwalk or path, greenery or vegetation, and an outdoor or natural setting.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step09_AsFunctionTool", + ProjectPath = "samples/02-agents/Agents/Agent_Step09_AsFunctionTool", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should be a response about the weather in Amsterdam, written in French.", + "The response should reference the tool result: cloudy weather with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step10_BackgroundResponsesWithToolsAndPersistence", + ProjectPath = "samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a generated novel or story.", + "The output may include tool invocation messages like '[ResearchSpaceFacts]' or '[GenerateCharacterProfiles]'.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step11_Middleware", + ProjectPath = "samples/02-agents/Agents/Agent_Step11_Middleware", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + // Example 4 prompts for approval; provide "Y" for each possible tool call + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain multiple examples demonstrating different middleware patterns.", + "It should include sections with '===' headers for different middleware examples.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step12_Plugins", + ProjectPath = "samples/02-agents/Agents/Agent_Step12_Plugins", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain information about both the current time and the weather in Seattle.", + "The weather information should reference the plugin result: cloudy with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step13_ChatReduction", + ProjectPath = "samples/02-agents/Agents/Agent_Step13_ChatReduction", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["Chat history has", "messages."], + ExpectedOutputDescription = + [ + "The output should contain joke responses about a pirate, a robot, and a lemur.", + "Between each response there should be a 'Chat history has N messages.' line showing the message count.", + "There should be a fourth response after the user asks about the first joke. Due to chat reduction, the agent may not remember the pirate joke — any response is acceptable (including repeating another joke or saying it doesn't remember).", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step14_BackgroundResponses", + ProjectPath = "samples/02-agents/Agents/Agent_Step14_BackgroundResponses", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a generated story or novel text about otters in space.", + "The text may appear in two parts: first a polled-to-completion result, then a streamed continuation.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step16_Declarative", + ProjectPath = "samples/02-agents/Agents/Agent_Step16_Declarative", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a response in JSON format with 'language' and 'answer' fields, since the declarative agent is configured to respond in JSON.", + "The content should be a joke about a pirate in English.", + "There should be both a non-streaming and streaming response.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step17_AdditionalAIContext", + ProjectPath = "samples/02-agents/Agents/Agent_Step17_AdditionalAIContext", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show a personal assistant managing a todo list across multiple turns.", + "The assistant should acknowledge adding items like picking up milk, taking Sally to soccer practice, and making a dentist appointment for Jimmy.", + "There should be a JSON block showing the serialized session state.", + "The final response should reference the calendar appointments (doctor at 15:00, team meeting at 17:00, birthday party at 20:00).", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step18_CompactionPipeline", + ProjectPath = "samples/02-agents/Agents/Agent_Step18_CompactionPipeline", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["[User]", "[Agent]"], + ExpectedOutputDescription = + [ + "The output should show a turn-by-turn conversation between [User] and [Agent] about shopping for electronics (laptops, keyboards, mice).", + "The output may include '[Messages: #N]' lines showing chat history compaction.", + "The agent should provide information about product prices from tool results.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step19_InFunctionLoopCheckpointing", + ProjectPath = "samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_RESPONSES_STORE"], + MustContain = ["=== Non-Streaming Mode ===", "=== Streaming Mode ==="], + ExpectedOutputDescription = + [ + "The output should show non-streaming and streaming modes demonstrating in-function-loop checkpointing with multi-turn conversations.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentSkills ───────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Agent_Step01_FileBasedSkills", + ProjectPath = "samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = + [ + "Converting units with file-based skills", + "Agent:", + ], + ExpectedOutputDescription = + [ + "The output should show the agent converting 26.2 miles to kilometers and 75 kilograms to pounds.", + "The response should contain approximate numeric values for both conversions.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentWithMemory ───────────────────────────────────────────────── + + new SampleDefinition + { + Name = "AgentWithMemory_Step01_ChatHistoryMemory", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain two joke responses.", + "The first joke should be about a pirate (as explicitly requested).", + "The second joke should also be pirate-themed or similar to what the user likes, since the memory system should recall the user's preference for pirate jokes from the first session.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithMemory_Step04_MemoryUsingFoundry", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MEMORY_STORE_ID", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_EMBEDDING_DEPLOYMENT_NAME"], + MustContain = + [ + ">> Setting up Foundry Memory Store", + ">> Serialize and deserialize the session to demonstrate persisted state", + ">> Start a new session that shares the same Foundry Memory scope", + ], + ExpectedOutputDescription = + [ + "The output should show a Foundry Memory Store being set up and processing updates.", + "After serialization/deserialization, the agent should recall previously learned information.", + "In the new session section, the agent should know facts from the earlier session due to shared Foundry Memory.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithMemory_Step05_BoundedChatHistory", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + MustContain = + [ + "--- Filling the session window", + "--- Next exchange will trigger overflow to vector store ---", + "--- Asking about overflowed information", + ], + ExpectedOutputDescription = + [ + "The output should demonstrate bounded chat history with overflow to a vector store.", + "After the window fills up and overflows, the agent should still be able to recall older information (like a favorite color) from the vector store.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentWithRAG ──────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "AgentWithRAG_Step01_BasicTextRAG", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + MustContain = [">> Asking about returns", ">> Asking about shipping", ">> Asking about product care"], + ExpectedOutputDescription = + [ + "The returns section should mention a 30-day return policy, unused condition, and original packaging.", + "The shipping section should mention 3-5 business days for standard shipping.", + "The product care section should mention tent fabric maintenance tips like using lukewarm water, non-detergent soap, and air drying.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithRAG_Step03_CustomRAGDataSource", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = [">> Asking about returns", ">> Asking about shipping", ">> Asking about product care"], + ExpectedOutputDescription = + [ + "The returns section should mention a 30-day return policy.", + "The shipping section should mention 3-5 business days for standard shipping.", + "The product care section should mention tent fabric maintenance tips.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithRAG_Step04_FoundryServiceRAG", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = [">> Asking about returns", ">> Asking about shipping", ">> Asking about product care"], + ExpectedOutputDescription = + [ + "The returns section should mention a 30-day return policy.", + "The shipping section should mention standard shipping timeframes.", + "The product care section should mention tent fabric maintenance tips.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentsWithFoundry ──────────────────────────────────────────────── + + new SampleDefinition + { + Name = "FoundryAgent_Step00_FoundryAgentLifecycle", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step01_Basics", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke response from the agent.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step02.1_MultiturnConversation", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain multiple joke responses showing a multi-turn conversation.", + "There should be both non-streaming and streaming responses, with the second turn in each building on the first (e.g., adding emojis or pirate voice).", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step02.2_MultiturnWithServerConversations", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should demonstrate server-side conversation sessions with non-streaming and streaming turns.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step03_UsingFunctionTools", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain weather information about Amsterdam from a function tool.", + "The response should mention cloudy weather with a high of 15°C (from the canned tool response).", + "There should be both a non-streaming and streaming response.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step04_UsingFunctionToolsWithApprovals", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain a prompt asking the user to approve a tool call, followed by weather information about Amsterdam.", + "The response should mention cloudy weather with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step05_StructuredOutput", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = ["Assistant Output:", "Name:"], + ExpectedOutputDescription = + [ + "The output should contain structured person information with Name, Age, and Occupation fields.", + "There should be both a direct structured output and a streamed-then-deserialized output.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step06_PersistedConversations", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a pirate joke, then after session persistence, a second response retelling the joke in pirate voice with emojis.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step08_DependencyInjection", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Tell me a joke about a pirate", ""], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate in response to the user's request.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step10_UsingImages", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should describe an image of a nature walkway or boardwalk scene.", + "It should mention elements like a wooden path, greenery, and an outdoor setting.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step11_AsFunctionTool", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should be a response about the weather in Amsterdam, written in French.", + "The response should reference the tool result: cloudy weather with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step12_Middleware", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain multiple middleware examples with '===' section headers.", + "The human-in-the-loop example should show tool approval prompts and agent responses.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step13_Plugins", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain information about both the current time and the weather in Seattle.", + "The weather information should reference the plugin result: cloudy with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step14_CodeInterpreter", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show the code interpreter being used to solve sin(x) + x^2 = 42, including a 'Code Input:' section with Python code.", + "It should show a 'Code Input:' section with Python code for the math problem.", + "It may show a 'Code Tool Result:' section with computed answers, or annotations with file references.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step16_FileSearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = ["--- Running File Search Agent ---"], + ExpectedOutputDescription = + [ + "The output should show a file being uploaded and indexed in a vector store, then an agent answering a question based on the file content.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step17_OpenAPITools", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a list of countries or information about countries that use the EUR currency.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── Skipped samples ───────────────────────────────────────────────── + + new SampleDefinition + { + Name = "AgentOpenTelemetry", + ProjectPath = "samples/02-agents/AgentOpenTelemetry", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Requires Aspire Dashboard / Docker for OpenTelemetry collection.", + }, + + new SampleDefinition + { + Name = "Agent_With_A2A", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_A2A", + RequiredEnvironmentVariables = ["A2A_AGENT_HOST"], + SkipReason = "Requires an external A2A agent host.", + }, + + new SampleDefinition + { + Name = "Agent_With_Anthropic", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_Anthropic", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME", "ANTHROPIC_RESOURCE"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_GitHubCopilot", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_GitHubCopilot", + RequiredEnvironmentVariables = [], + // The sample prompts for shell command approval; provide "Y" for each possible permission request + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain a user prompt and a response listing files in the current directory.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_GoogleGemini", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_GoogleGemini", + RequiredEnvironmentVariables = ["GOOGLE_GENAI_API_KEY"], + OptionalEnvironmentVariables = ["GOOGLE_GENAI_MODEL"], + MustContain = + [ + "Google GenAI client based agent response:", + "Community client based agent response:", + ], + ExpectedOutputDescription = + [ + "The output should contain two labeled sections, each with a joke about a pirate from a different Gemini client.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_ONNX", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_ONNX", + RequiredEnvironmentVariables = ["ONNX_MODEL_PATH"], + SkipReason = "Requires local ONNX model.", + }, + + new SampleDefinition + { + Name = "Agent_With_Ollama", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_Ollama", + RequiredEnvironmentVariables = ["OLLAMA_ENDPOINT", "OLLAMA_MODEL_NAME"], + SkipReason = "Requires local Ollama server.", + }, + + new SampleDefinition + { + Name = "Agent_With_OpenAIChatCompletion", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_OpenAIResponses", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_OpenAIResponses", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step05_Observability", + ProjectPath = "samples/02-agents/Agents/Agent_Step05_Observability", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "APPLICATIONINSIGHTS_CONNECTION_STRING"], + SkipReason = "Requires Application Insights / OpenTelemetry infrastructure.", + }, + + new SampleDefinition + { + Name = "Agent_Step07_AsMcpTool", + ProjectPath = "samples/02-agents/Agents/Agent_Step07_AsMcpTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "Agent_Step15_DeepResearch", + ProjectPath = "samples/02-agents/Agents/Agent_Step15_DeepResearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_BING_CONNECTION_ID"], + OptionalEnvironmentVariables = ["AZURE_AI_REASONING_DEPLOYMENT_NAME"], + SkipReason = "Requires Azure AI Foundry project with Bing search connection.", + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step01_Running", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step02_Reasoning", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + MustContain = + [ + "1. Non-streaming:", + "#### Start Thinking ####", + "#### End Thinking ####", + "#### Final Answer ####", + "Token usage:", + "2. Streaming", + ], + ExpectedOutputDescription = + [ + "The non-streaming section should show the agent's reasoning about a math problem, followed by a final answer.", + "The streaming section should show reasoning and a response about the theory of relativity.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step03_UsingFunctionTools", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain information about the weather in Amsterdam.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step04_UsingSkills", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step04_UsingSkills", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + MustContain = + [ + "Creating a presentation about renewable energy...", + "#### Agent Response ####", + ], + ExpectedOutputDescription = + [ + "The output should show the agent creating a presentation about renewable energy.", + "There should be an agent response section with content about renewable energy sources.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithMemory_Step02_MemoryUsingMem0", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME", "MEM0_ENDPOINT", "MEM0_API_KEY"], + SkipReason = "Requires Mem0 service.", + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step01_Running", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step02_Reasoning", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + MustContain = + [ + "1. Non-streaming:", + "Token usage:", + "2. Streaming", + ], + ExpectedOutputDescription = + [ + "The non-streaming section should show the agent's reasoning about a math problem, followed by a final answer.", + "The streaming section should show reasoning and a response about the theory of relativity.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step03_CreateFromChatClient", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step04_CreateFromOpenAIResponseClient", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step05_Conversation", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + MustContain = + [ + "=== Multi-turn Conversation Demo ===", + "Conversation created.", + "Conversation ID:", + ], + ExpectedOutputDescription = + [ + "The output should show a multi-turn conversation about France: capital, landmarks, and height of the most famous one.", + "The output should show the conversation history retrieved from the server.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithRAG_Step02_CustomVectorStoreRAG", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + SkipReason = "Requires external Qdrant vector store.", + }, + + new SampleDefinition + { + Name = "DeclarativeAgents_ChatClient", + ProjectPath = "samples/02-agents/DeclarativeAgents/ChatClient", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Requires command-line arguments (YAML file path) with no YAML files checked in.", + }, + + new SampleDefinition + { + Name = "DevUI_Step01_BasicUsage", + ProjectPath = "samples/02-agents/DevUI/DevUI_Step01_BasicUsage", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step07_Observability", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "APPLICATIONINSIGHTS_CONNECTION_STRING"], + SkipReason = "Requires Application Insights / OpenTelemetry infrastructure.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step09_UsingMcpClientAsTools", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an agent using the Microsoft Learn MCP tool to search or retrieve documentation.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step15_ComputerUse", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = ["The output should show a computer automation session processing simulated browser screenshots with iteration steps and a final response describing search results."], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step18_BingCustomSearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID", "AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME"], + SkipReason = "Requires Bing Custom Search connection.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step19_SharePoint", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "SHAREPOINT_PROJECT_CONNECTION_ID"], + SkipReason = "Requires SharePoint connection.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step20_MicrosoftFabric", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "FABRIC_PROJECT_CONNECTION_ID"], + SkipReason = "Requires Microsoft Fabric connection.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step21_WebSearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an agent using web search to answer a question, with response text and citation annotations.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step22_MemorySearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_EMBEDDING_DEPLOYMENT_NAME"], + OptionalEnvironmentVariables = ["AZURE_AI_MEMORY_STORE_ID"], + MustContain = ["Agent created with Memory Search tool. Starting conversation..."], + ExpectedOutputDescription = + [ + "The output should show a memory store being created, memories stored from a prior conversation, and an agent querying those memories.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step23_LocalMCP", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = ["The output should show an agent using the Microsoft Learn MCP server to search for documentation and provide a response."], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Hosted_MCP", + ProjectPath = "samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y", "Y", "Y"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should contain a summary or information about Azure AI documentation from Microsoft Learn."], + }, + + new SampleDefinition + { + Name = "ResponseAgent_Hosted_MCP", + ProjectPath = "samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y", "Y", "Y"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should contain a summary or information about Azure AI documentation from Microsoft Learn."], + }, + + new SampleDefinition + { + Name = "Agent_MCP_Server", + ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "Agent_MCP_Server_Auth", + ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step01_GettingStarted_Client", + ProjectPath = "samples/02-agents/AGUI/Step01_GettingStarted/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step01_GettingStarted_Server", + ProjectPath = "samples/02-agents/AGUI/Step01_GettingStarted/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step02_BackendTools_Client", + ProjectPath = "samples/02-agents/AGUI/Step02_BackendTools/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step02_BackendTools_Server", + ProjectPath = "samples/02-agents/AGUI/Step02_BackendTools/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step03_FrontendTools_Client", + ProjectPath = "samples/02-agents/AGUI/Step03_FrontendTools/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step03_FrontendTools_Server", + ProjectPath = "samples/02-agents/AGUI/Step03_FrontendTools/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step04_HumanInLoop_Client", + ProjectPath = "samples/02-agents/AGUI/Step04_HumanInLoop/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step04_HumanInLoop_Server", + ProjectPath = "samples/02-agents/AGUI/Step04_HumanInLoop/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step05_StateManagement_Client", + ProjectPath = "samples/02-agents/AGUI/Step05_StateManagement/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step05_StateManagement_Server", + ProjectPath = "samples/02-agents/AGUI/Step05_StateManagement/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + ]; +} diff --git a/dotnet/eng/verify-samples/ConsoleReporter.cs b/dotnet/eng/verify-samples/ConsoleReporter.cs new file mode 100644 index 0000000000..0b21138d79 --- /dev/null +++ b/dotnet/eng/verify-samples/ConsoleReporter.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Thread-safe console output with sample-name prefixes and colored status. +/// +internal sealed class ConsoleReporter +{ + private readonly object _lock = new(); + + /// + /// Writes a complete prefixed line atomically to the console. + /// + public void WriteLineWithPrefix(string sampleName, string message, ConsoleColor? color = null) + { + lock (this._lock) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"[{sampleName}] "); + if (color.HasValue) + { + Console.ForegroundColor = color.Value; + } + else + { + Console.ResetColor(); + } + + Console.WriteLine(message); + Console.ResetColor(); + } + } + + /// + /// Prints the final summary table and elapsed time to the console. + /// + public void PrintSummary( + IReadOnlyList orderedResults, + IReadOnlyList<(string Name, string Reason)> skipped, + TimeSpan elapsed) + { + var passCount = orderedResults.Count(r => r.Passed); + var failCount = orderedResults.Count(r => !r.Passed); + + Console.WriteLine(); + Console.WriteLine(new string('─', 60)); + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine("SUMMARY"); + Console.ResetColor(); + + foreach (var result in orderedResults) + { + Console.ForegroundColor = result.Passed ? ConsoleColor.Green : ConsoleColor.Red; + Console.Write(result.Passed ? " ✓ " : " ✗ "); + Console.ResetColor(); + Console.WriteLine($"{result.SampleName}: {result.Summary}"); + } + + foreach (var (name, reason) in skipped) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write(" ○ "); + Console.ResetColor(); + Console.WriteLine($"{name}: Skipped — {reason}"); + } + + Console.WriteLine(); + Console.Write("Results: "); + Console.ForegroundColor = ConsoleColor.Green; + Console.Write($"{passCount} passed"); + Console.ResetColor(); + + if (failCount > 0) + { + Console.Write(", "); + Console.ForegroundColor = ConsoleColor.Red; + Console.Write($"{failCount} failed"); + Console.ResetColor(); + } + + if (skipped.Count > 0) + { + Console.Write(", "); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"{skipped.Count} skipped"); + Console.ResetColor(); + } + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"Elapsed: {elapsed.Hours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"); + Console.ResetColor(); + } +} diff --git a/dotnet/eng/verify-samples/CsvResultWriter.cs b/dotnet/eng/verify-samples/CsvResultWriter.cs new file mode 100644 index 0000000000..9a1128dcba --- /dev/null +++ b/dotnet/eng/verify-samples/CsvResultWriter.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; + +namespace VerifySamples; + +/// +/// Writes a CSV summary of sample verification results. +/// +internal static class CsvResultWriter +{ + /// + /// Writes the results to a CSV file at the specified path. + /// + public static async Task WriteAsync( + string path, + IReadOnlyList orderedResults, + IReadOnlyList<(string Name, string Reason)> skipped, + IReadOnlyList samples) + { + var pathLookup = samples.ToDictionary(s => s.Name, s => s.ProjectPath); + + var sb = new StringBuilder(); + sb.AppendLine("Sample,ProjectPath,Status,FailedChecks,Failures"); + + foreach (var result in orderedResults) + { + var status = result.Passed ? "PASSED" : "FAILED"; + var failedChecks = result.Failures.Count; + var failures = string.Join("; ", result.Failures); + pathLookup.TryGetValue(result.SampleName, out var projectPath); + sb.AppendLine($"{CsvEscape(result.SampleName)},{CsvEscape(projectPath ?? "")},{status},{failedChecks},{CsvEscape(failures)}"); + } + + foreach (var (name, reason) in skipped) + { + pathLookup.TryGetValue(name, out var projectPath); + sb.AppendLine($"{CsvEscape(name)},{CsvEscape(projectPath ?? "")},SKIPPED,0,{CsvEscape(reason)}"); + } + + await File.WriteAllTextAsync(path, sb.ToString()); + } + + /// + /// Escapes a value for CSV: wraps in quotes if it contains commas, quotes, or newlines. + /// + private static string CsvEscape(string value) + { + if (value.Contains('"') || value.Contains(',') || value.Contains('\n') || value.Contains('\r')) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } +} diff --git a/dotnet/eng/verify-samples/GetStartedSamples.cs b/dotnet/eng/verify-samples/GetStartedSamples.cs new file mode 100644 index 0000000000..9298e39388 --- /dev/null +++ b/dotnet/eng/verify-samples/GetStartedSamples.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Defines the expected behavior for each sample in 01-get-started. +/// +internal static class GetStartedSamples +{ + public static IReadOnlyList All { get; } = + [ + new SampleDefinition + { + Name = "05_first_workflow", + ProjectPath = "samples/01-get-started/05_first_workflow", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "UppercaseExecutor: HELLO, WORLD!", + "ReverseTextExecutor: !DLROW ,OLLEH", + ], + }, + + new SampleDefinition + { + Name = "01_hello_agent", + ProjectPath = "samples/01-get-started/01_hello_agent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two separate joke responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "02_add_tools", + ProjectPath = "samples/01-get-started/02_add_tools", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = [], + ExpectedOutputDescription = + [ + "The output should contain information about the weather in Amsterdam.", + "The response should mention that it is cloudy with a high of 15°C (or equivalent), since this comes from a tool that returns a canned response.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "03_multi_turn", + ProjectPath = "samples/01-get-started/03_multi_turn", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "After the initial joke, there should be a modified version that includes emojis and is told in the voice of a pirate's parrot.", + "The pattern repeats: first a non-streaming pirate joke + parrot version, then a streaming pirate joke + parrot version.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "04_memory", + ProjectPath = "samples/01-get-started/04_memory", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = + [ + ">> Use session with blank memory", + ">> Use deserialized session with previously created memories", + ">> Read memories using memory component", + "MEMORY - User Name:", + "MEMORY - User Age:", + ">> Use new session with previously created memories", + ], + ExpectedOutputDescription = + [ + "In the 'Use session with blank memory' section, the agent should respond to the user's messages. It may ask for the user's name or age if not yet known.", + "In the 'Use deserialized session with previously created memories' section, the agent should correctly recall that the user's name is Ruaidhrí and age is 20.", + "The 'MEMORY - User Name:' line should show 'Ruaidhrí' (or a close transliteration).", + "The 'MEMORY - User Age:' line should show '20'.", + "In the 'Use new session with previously created memories' section, the agent should know the user's name and age from the transferred memory.", + "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 = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Requires Azure Functions Core Tools runtime and starts a web server.", + }, + ]; +} diff --git a/dotnet/eng/verify-samples/LogFileWriter.cs b/dotnet/eng/verify-samples/LogFileWriter.cs new file mode 100644 index 0000000000..a46096f3d6 --- /dev/null +++ b/dotnet/eng/verify-samples/LogFileWriter.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; + +namespace VerifySamples; + +/// +/// Incrementally writes a sequential (non-interleaved) log file, appending after each sample completes. +/// Thread-safe: multiple parallel tasks may call write methods concurrently. +/// +internal sealed class LogFileWriter : IDisposable +{ + private readonly string _path; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public LogFileWriter(string path) + { + this._path = path; + } + + /// + public void Dispose() + { + this._writeLock.Dispose(); + } + + /// + /// Writes the log file header. Call once at the start of the run. + /// + public async Task WriteHeaderAsync() + { + var sb = new StringBuilder(); + sb.AppendLine($"Sample Verification Log — {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + sb.AppendLine(new string('═', 72)); + sb.AppendLine(); + + await File.WriteAllTextAsync(this._path, sb.ToString()); + } + + /// + /// Appends a skipped-sample entry to the log file. + /// + public async Task WriteSkippedAsync(string name, string reason) + { + var sb = new StringBuilder(); + sb.AppendLine($"── {name} ──"); + sb.AppendLine($"Status: SKIPPED — {reason}"); + sb.AppendLine(); + + await this.AppendAsync(sb.ToString()); + } + + /// + /// Appends a completed sample's full output section to the log file. + /// + public async Task WriteSampleResultAsync(VerificationResult result) + { + var sb = new StringBuilder(); + sb.AppendLine(new string('─', 72)); + sb.AppendLine($"── {result.SampleName} ──"); + sb.AppendLine($"Status: {(result.Passed ? "PASSED" : "FAILED")}"); + sb.AppendLine(); + + foreach (var line in result.LogLines) + { + sb.AppendLine(line); + } + + sb.AppendLine(); + + if (!string.IsNullOrWhiteSpace(result.Stdout)) + { + sb.AppendLine("--- stdout ---"); + sb.AppendLine(result.Stdout.TrimEnd()); + sb.AppendLine("--- end stdout ---"); + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(result.Stderr)) + { + sb.AppendLine("--- stderr ---"); + sb.AppendLine(result.Stderr.TrimEnd()); + sb.AppendLine("--- end stderr ---"); + sb.AppendLine(); + } + + if (result.Failures.Count > 0) + { + sb.AppendLine("Failures:"); + foreach (var failure in result.Failures) + { + sb.AppendLine($" ✗ {failure}"); + } + + sb.AppendLine(); + } + + if (result.AIReasoning is not null) + { + sb.AppendLine("AI Reasoning:"); + sb.AppendLine(result.AIReasoning); + sb.AppendLine(); + } + + await this.AppendAsync(sb.ToString()); + } + + /// + /// Appends the final summary section and elapsed time to the log file. + /// + public async Task WriteSummaryAsync( + IReadOnlyList orderedResults, + IReadOnlyList<(string Name, string Reason)> skipped, + TimeSpan elapsed) + { + var passCount = orderedResults.Count(r => r.Passed); + var failCount = orderedResults.Count(r => !r.Passed); + + var sb = new StringBuilder(); + sb.AppendLine(new string('═', 72)); + sb.AppendLine("SUMMARY"); + sb.AppendLine(); + + foreach (var result in orderedResults) + { + sb.AppendLine($" {(result.Passed ? "✓" : "✗")} {result.SampleName}: {result.Summary}"); + } + + foreach (var (name, reason) in skipped) + { + sb.AppendLine($" ○ {name}: Skipped — {reason}"); + } + + sb.AppendLine(); + sb.AppendLine($"Results: {passCount} passed{(failCount > 0 ? $", {failCount} failed" : "")}{(skipped.Count > 0 ? $", {skipped.Count} skipped" : "")}"); + sb.AppendLine($"Elapsed: {elapsed.Hours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"); + + await this.AppendAsync(sb.ToString()); + } + + private async Task AppendAsync(string text) + { + await this._writeLock.WaitAsync(); + try + { + await File.AppendAllTextAsync(this._path, text); + } + finally + { + this._writeLock.Release(); + } + } +} diff --git a/dotnet/eng/verify-samples/MarkdownResultWriter.cs b/dotnet/eng/verify-samples/MarkdownResultWriter.cs new file mode 100644 index 0000000000..cf13b6d1b0 --- /dev/null +++ b/dotnet/eng/verify-samples/MarkdownResultWriter.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; + +namespace VerifySamples; + +/// +/// Writes a Markdown summary of sample verification results. +/// +internal static class MarkdownResultWriter +{ + /// + /// Writes the results to a Markdown file at the specified path. + /// + public static async Task WriteAsync( + string path, + IReadOnlyList orderedResults, + IReadOnlyList<(string Name, string Reason)> skipped, + TimeSpan elapsed) + { + var passCount = orderedResults.Count(r => r.Passed); + var failCount = orderedResults.Count(r => !r.Passed); + + var sb = new StringBuilder(); + sb.AppendLine("# Sample Verification Results"); + sb.AppendLine(); + sb.AppendLine($"**{passCount} passed, {failCount} failed, {skipped.Count} skipped** | Elapsed: {elapsed.Hours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"); + sb.AppendLine(); + + // Results table + sb.AppendLine("| Sample | Status | Failed Checks | Failures |"); + sb.AppendLine("|--------|--------|---------------|----------|"); + + foreach (var result in orderedResults) + { + var status = result.Passed ? "✅ PASSED" : "❌ FAILED"; + var failedChecks = result.Failures.Count; + var failures = MdEscape(string.Join("; ", result.Failures)); + sb.AppendLine($"| {MdEscape(result.SampleName)} | {status} | {failedChecks} | {failures} |"); + } + + foreach (var (name, reason) in skipped) + { + sb.AppendLine($"| {MdEscape(name)} | ⏭️ SKIPPED | 0 | {MdEscape(reason)} |"); + } + + // Collapsible AI reasoning details for failures + var failures2 = orderedResults.Where(r => !r.Passed && !string.IsNullOrEmpty(r.AIReasoning)).ToList(); + if (failures2.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Failure Details"); + sb.AppendLine(); + + foreach (var result in failures2) + { + sb.AppendLine($"
{HtmlEscape(result.SampleName)}"); + sb.AppendLine(); + if (result.Failures.Count > 0) + { + foreach (var failure in result.Failures) + { + sb.AppendLine($"- {MdEscape(failure)}"); + } + + sb.AppendLine(); + } + + sb.AppendLine("**AI Reasoning:**"); + sb.AppendLine(); + sb.AppendLine("```"); + sb.AppendLine(result.AIReasoning); + sb.AppendLine("```"); + sb.AppendLine(); + sb.AppendLine("
"); + sb.AppendLine(); + } + } + + await File.WriteAllTextAsync(path, sb.ToString()); + } + + /// + /// Escapes pipe characters and newlines for use inside Markdown table cells. + /// + private static string MdEscape(string value) + { + return value.Replace("|", "\\|").Replace("\n", " ").Replace("\r", ""); + } + + /// + /// Escapes HTML special characters for use inside HTML tags. + /// + private static string HtmlEscape(string value) + { + return value.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); + } +} diff --git a/dotnet/eng/verify-samples/Program.cs b/dotnet/eng/verify-samples/Program.cs new file mode 100644 index 0000000000..7f27d37dd5 --- /dev/null +++ b/dotnet/eng/verify-samples/Program.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This tool runs the 01-get-started, 02-agents, and 03-workflows samples and verifies their output. +// Deterministic samples are verified with exact string matching. +// Non-deterministic (LLM) samples are verified using an agent-framework agent. +// +// Usage: +// dotnet run # Run all samples +// dotnet run -- 01_hello_agent 05_first_workflow # Run specific samples by name +// dotnet run -- --category 01-get-started # Run the 01-get-started category +// dotnet run -- --category 02-agents # Run the 02-agents category +// dotnet run -- --category 03-workflows # Run the 03-workflows category +// dotnet run -- --parallel 16 # Run up to 16 samples concurrently +// dotnet run -- --log results.log # Write sequential log to file +// dotnet run -- --csv results.csv # Write CSV summary to file +// dotnet run -- --md results.md # Write Markdown summary to file +// +// Required environment variables (for AI-powered samples): +// AZURE_OPENAI_ENDPOINT +// AZURE_OPENAI_DEPLOYMENT_NAME (optional, defaults to gpt-5-mini) + +using System.Diagnostics; +using Azure.AI.OpenAI; +using Azure.Identity; +using VerifySamples; + +var options = VerifyOptions.Parse(args); +if (options is null) +{ + return 1; +} + +var stopwatch = Stopwatch.StartNew(); + +// Resolve the dotnet/ root directory (verify-samples is at dotnet/eng/verify-samples/) +var dotnetRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); +if (!File.Exists(Path.Combine(dotnetRoot, "agent-framework-dotnet.slnx"))) +{ + dotnetRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..")); +} + +// Set up the AI verifier +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT"); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5-mini"; + +OpenAI.Chat.ChatClient? chatClient = null; +if (!string.IsNullOrEmpty(endpoint)) +{ + chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName); +} + +// Set up optional log file writer +LogFileWriter? logWriter = null; +if (options.LogFilePath is not null) +{ + logWriter = new LogFileWriter(options.LogFilePath); + await logWriter.WriteHeaderAsync(); +} + +try +{ + // Run all samples + var reporter = new ConsoleReporter(); + var verifier = new SampleVerifier(chatClient); + var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter); + + var run = await orchestrator.RunAllAsync(options.Samples, options.MaxParallelism); + + stopwatch.Stop(); + + // Print summary + var orderedResults = run.SampleOrder + .Where(run.Results.ContainsKey) + .Select(name => run.Results[name]) + .ToList(); + + reporter.PrintSummary(orderedResults, run.Skipped, stopwatch.Elapsed); + + // Write log file summary + if (logWriter is not null) + { + await logWriter.WriteSummaryAsync(orderedResults, run.Skipped, stopwatch.Elapsed); + Console.WriteLine($"Log written to: {options.LogFilePath}"); + } + + // Write CSV summary + if (options.CsvFilePath is not null) + { + await CsvResultWriter.WriteAsync(options.CsvFilePath, orderedResults, run.Skipped, options.Samples); + Console.WriteLine($"CSV written to: {options.CsvFilePath}"); + } + + // Write Markdown summary + if (options.MarkdownFilePath is not null) + { + await MarkdownResultWriter.WriteAsync(options.MarkdownFilePath, orderedResults, run.Skipped, stopwatch.Elapsed); + Console.WriteLine($"Markdown written to: {options.MarkdownFilePath}"); + } + + return orderedResults.Any(r => !r.Passed) ? 1 : 0; +} +finally +{ + logWriter?.Dispose(); +} diff --git a/dotnet/eng/verify-samples/SampleDefinition.cs b/dotnet/eng/verify-samples/SampleDefinition.cs new file mode 100644 index 0000000000..5f5f69a40e --- /dev/null +++ b/dotnet/eng/verify-samples/SampleDefinition.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Describes a sample to verify, including its expected output. +/// +internal sealed class SampleDefinition +{ + /// + /// Display name for the sample (e.g., "01_hello_agent"). + /// + public required string Name { get; init; } + + /// + /// Relative path from the dotnet/ directory to the sample project directory. + /// + public required string ProjectPath { get; init; } + + /// + /// Environment variables that the sample requires for a meaningful run. + /// The runner checks these before running and will skip the sample if any are unset, + /// recording a skip reason that indicates which required variables are missing. + /// + public string[] RequiredEnvironmentVariables { get; init; } = []; + + /// + /// Environment variables that the sample can use but typically has fallbacks or defaults for. + /// If these are not set, the sample might prompt or behave interactively, which could cause + /// automated verification to hang. The runner checks these and skips the sample if they are unset + /// to avoid non-deterministic or blocking behavior in automated runs. + /// + public string[] OptionalEnvironmentVariables { get; init; } = []; + + /// + /// If set, the sample is skipped with this reason. + /// Use only for structural reasons (e.g., web server, multi-process, needs external service). + /// Do NOT use for missing environment variables — those are checked dynamically. + /// + public string? SkipReason { get; init; } + + /// + /// Substrings that must appear in stdout for the sample to pass. + /// Used for deterministic verification. + /// + public string[] MustContain { get; init; } = []; + + /// + /// Substrings that must not appear in stdout for the sample to pass. + /// + public string[] MustNotContain { get; init; } = []; + + /// + /// If true, entries cover the entire expected output — + /// no AI verification is needed. + /// + public bool IsDeterministic { get; init; } + + /// + /// Natural-language description of what the sample output should look like. + /// Used by the AI verifier for non-deterministic samples. + /// Each entry describes one aspect of the expected output that should be verified. + /// + public string[] ExpectedOutputDescription { get; init; } = []; + + /// + /// Sequence of stdin inputs to feed to the sample process. + /// Each entry is written as a line (followed by newline) to the process stdin. + /// A null entry inserts a delay without writing anything. + /// Inputs are sent with a short delay between each to allow the process to prompt. + /// + public string?[] Inputs { get; init; } = []; + + /// + /// Delay in milliseconds between each input line. Default is 2000ms. + /// Increase for samples that need more time between prompts (e.g., LLM calls between inputs). + /// + public int InputDelayMs { get; init; } = 2000; +} diff --git a/dotnet/eng/verify-samples/SampleRunner.cs b/dotnet/eng/verify-samples/SampleRunner.cs new file mode 100644 index 0000000000..0fabd82262 --- /dev/null +++ b/dotnet/eng/verify-samples/SampleRunner.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; + +namespace VerifySamples; + +/// +/// Result of running a sample process. +/// +internal sealed record SampleRunResult( + string Stdout, + string Stderr, + int ExitCode, + TimeSpan Elapsed); + +/// +/// Runs a sample project via dotnet run and captures its output. +/// +internal static class SampleRunner +{ + /// + /// Runs dotnet run --framework net10.0 in the given project directory. + /// + public static Task RunAsync( + string projectPath, + TimeSpan timeout, + CancellationToken cancellationToken = default) + => RunAsync(projectPath, "run --framework net10.0", timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken); + + /// + /// Runs dotnet run --framework net10.0 with stdin inputs. + /// + public static Task RunAsync( + string projectPath, + TimeSpan timeout, + string?[]? inputs, + int inputDelayMs = 2000, + CancellationToken cancellationToken = default) + => RunAsync(projectPath, "run --framework net10.0", timeout, inputs, inputDelayMs, cancellationToken); + + /// + /// Runs an arbitrary dotnet command in the given working directory. + /// + public static async Task RunAsync( + string workingDirectory, + string dotnetArgs, + TimeSpan timeout, + string?[]? inputs = null, + int inputDelayMs = 0, + CancellationToken cancellationToken = default) + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = dotnetArgs, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = inputs is { Length: > 0 }, + UseShellExecute = false, + CreateNoWindow = true, + }; + + var sw = Stopwatch.StartNew(); + + using var process = new Process { StartInfo = psi }; + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + // Feed stdin inputs with delays if configured + if (inputs is { Length: > 0 }) + { + _ = Task.Run(async () => + { + try + { + foreach (var input in inputs) + { + await Task.Delay(inputDelayMs, cancellationToken); + if (input is not null) + { + await process.StandardInput.WriteLineAsync(input.AsMemory(), cancellationToken); + await process.StandardInput.FlushAsync(cancellationToken); + } + } + + process.StandardInput.Close(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + // Process may have exited before all inputs were sent + } + }, cancellationToken); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timeout — kill the process + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort + } + + sw.Stop(); + return new SampleRunResult( + Stdout: await stdoutTask, + Stderr: $"TIMEOUT: Sample did not complete within {timeout.TotalSeconds}s.\n{await stderrTask}", + ExitCode: -1, + Elapsed: sw.Elapsed); + } + + sw.Stop(); + return new SampleRunResult( + Stdout: await stdoutTask, + Stderr: await stderrTask, + ExitCode: process.ExitCode, + Elapsed: sw.Elapsed); + } +} diff --git a/dotnet/eng/verify-samples/SampleVerifier.cs b/dotnet/eng/verify-samples/SampleVerifier.cs new file mode 100644 index 0000000000..9dc17b1769 --- /dev/null +++ b/dotnet/eng/verify-samples/SampleVerifier.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +namespace VerifySamples; + +/// +/// Verifies sample output using deterministic checks and an AI agent +/// for non-deterministic output validation. +/// +internal sealed class SampleVerifier +{ + private readonly AIAgent? _verifierAgent; + + /// + /// Creates a verifier. If is provided, + /// AI-based verification is available for non-deterministic samples. + /// + public SampleVerifier(ChatClient? chatClient = null) + { + if (chatClient is not null) + { + this._verifierAgent = chatClient.AsAIAgent( + instructions: """ + You are a test output verifier. You will be given: + 1. The actual stdout output of a program + 2. A list of expectations about what the output should contain or demonstrate + + Your job is to determine whether the actual output satisfies each expectation. + Be reasonable — the output comes from an LLM so exact wording won't match, but the + semantic intent should be clearly satisfied. + """, + name: "OutputVerifier"); + } + } + + /// + /// Verifies the output of a sample run against its definition. + /// + public async Task VerifyAsync(SampleDefinition sample, SampleRunResult run) + { + var failures = new List(); + + // 1. Exit code check + if (run.ExitCode != 0) + { + failures.Add($"Exit code was {run.ExitCode}, expected 0. Stderr: {Truncate(run.Stderr, 500)}"); + } + + // 2. Must-contain checks + foreach (var expected in sample.MustContain) + { + if (!run.Stdout.Contains(expected, StringComparison.Ordinal)) + { + failures.Add($"Output missing expected substring: \"{expected}\""); + } + } + + // 3. Must-not-contain checks + foreach (var unexpected in sample.MustNotContain) + { + if (run.Stdout.Contains(unexpected, StringComparison.Ordinal)) + { + failures.Add($"Output contains unexpected substring: \"{unexpected}\""); + } + } + + // 4. AI verification for non-deterministic samples + string? aiReasoning = null; + if (!sample.IsDeterministic && sample.ExpectedOutputDescription.Length > 0) + { + if (this._verifierAgent is null) + { + failures.Add("AI verification required but no AI agent configured (missing AZURE_OPENAI_ENDPOINT)."); + } + else + { + var aiResult = await this.VerifyWithAIAsync(run.Stdout, sample.ExpectedOutputDescription); + aiReasoning = aiResult.Reasoning; + + foreach (var unmet in aiResult.UnmetExpectations) + { + failures.Add($"AI expectation not met: {unmet}"); + } + } + } + + bool passed = failures.Count == 0; + return new VerificationResult + { + SampleName = sample.Name, + Passed = passed, + Summary = passed ? "All checks passed" : $"{failures.Count} check(s) failed", + Failures = failures, + AIReasoning = aiReasoning, + }; + } + + private async Task<(string Reasoning, List UnmetExpectations)> VerifyWithAIAsync( + string actualOutput, + string[] expectations) + { + var expectationList = string.Join("\n", expectations.Select((e, i) => $" {i + 1}. {e}")); + var prompt = $""" + Actual program output: + --- + {Truncate(actualOutput, 4000)} + --- + + Expectations to verify: + {expectationList} + + Does the output satisfy all expectations? + """; + + try + { + var response = await this._verifierAgent!.RunAsync(prompt); + var result = response.Result; + + if (result is null) + { + return ($"AI verification returned null result. Raw: {response.Text}", ["AI verification returned null result."]); + } + + var reasoning = result.Reasoning ?? "(no reasoning provided)"; + + // Collect unmet expectations as individual failures + var unmet = new List(); + if (result.ExpectationResults is { Count: > 0 }) + { + foreach (var er in result.ExpectationResults.Where(er => !er.Met)) + { + var detail = string.IsNullOrWhiteSpace(er.Detail) ? er.Expectation : $"{er.Expectation} — {er.Detail}"; + unmet.Add(detail ?? "Unknown expectation"); + } + + // If the model flagged overall failure but all individual expectations were met, + // still treat as failure using the overall reasoning. + if (unmet.Count == 0 && !result.Pass) + { + unmet.Add(reasoning); + } + } + else if (!result.Pass) + { + // Fallback: no per-expectation detail but overall pass is false + unmet.Add(reasoning); + } + + return (reasoning, unmet); + } + catch (Exception ex) + { + return ($"AI verification error: {ex.Message}", [$"AI verification error: {ex.Message}"]); + } + } + + private static string Truncate(string text, int maxLength) + => text.Length <= maxLength ? text : text[..maxLength] + "... (truncated)"; +} + +/// +/// Structured response from the AI verification agent. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization via RunAsync.")] +internal sealed class AIVerificationResponse +{ + /// Whether all expectations were met. + [JsonPropertyName("pass")] + public bool Pass { get; set; } + + /// Brief explanation of the overall assessment. + [JsonPropertyName("reasoning")] + public string? Reasoning { get; set; } + + /// Per-expectation results. + [JsonPropertyName("expectation_results")] + public List? ExpectationResults { get; set; } +} + +/// +/// Result for an individual expectation check. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization via RunAsync.")] +internal sealed class ExpectationResult +{ + /// The expectation text that was evaluated. + [JsonPropertyName("expectation")] + public string? Expectation { get; set; } + + /// Whether this expectation was met. + [JsonPropertyName("met")] + public bool Met { get; set; } + + /// Detail about how the expectation was or was not met. + [JsonPropertyName("detail")] + public string? Detail { get; set; } +} diff --git a/dotnet/eng/verify-samples/VerificationOrchestrator.cs b/dotnet/eng/verify-samples/VerificationOrchestrator.cs new file mode 100644 index 0000000000..1ce805bc5a --- /dev/null +++ b/dotnet/eng/verify-samples/VerificationOrchestrator.cs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; + +namespace VerifySamples; + +/// +/// Orchestrates sample verification: filters, runs in parallel, and collects results. +/// +internal sealed class VerificationOrchestrator +{ + private readonly SampleVerifier _verifier; + private readonly ConsoleReporter _reporter; + private readonly LogFileWriter? _logWriter; + private readonly string _dotnetRoot; + private readonly TimeSpan _timeout; + + public VerificationOrchestrator( + SampleVerifier verifier, + ConsoleReporter reporter, + string dotnetRoot, + TimeSpan timeout, + LogFileWriter? logWriter = null) + { + this._verifier = verifier; + this._reporter = reporter; + this._logWriter = logWriter; + this._dotnetRoot = dotnetRoot; + this._timeout = timeout; + } + + /// + /// The result of running all samples through the orchestrator. + /// + internal sealed record RunAllResult( + ConcurrentDictionary Results, + List<(string Name, string Reason)> Skipped, + List SampleOrder); + + /// + /// Filters samples, runs the runnable ones in parallel, and returns all results. + /// + public async Task RunAllAsync( + IReadOnlyList samples, + int maxParallelism) + { + var skipped = new List<(string Name, string Reason)>(); + var runnableSamples = new List(); + var sampleOrder = new List(); + + // Separate samples into skipped and runnable + foreach (var sample in samples) + { + sampleOrder.Add(sample.Name); + + if (sample.SkipReason is not null) + { + skipped.Add((sample.Name, sample.SkipReason)); + this._reporter.WriteLineWithPrefix(sample.Name, $"SKIPPED — {sample.SkipReason}", ConsoleColor.Yellow); + + if (this._logWriter is not null) + { + await this._logWriter.WriteSkippedAsync(sample.Name, sample.SkipReason); + } + + continue; + } + + var missingRequired = sample.RequiredEnvironmentVariables + .Where(v => string.IsNullOrEmpty(Environment.GetEnvironmentVariable(v))) + .ToList(); + + var missingOptional = sample.OptionalEnvironmentVariables + .Where(v => string.IsNullOrEmpty(Environment.GetEnvironmentVariable(v))) + .ToList(); + + if (missingRequired.Count > 0 || missingOptional.Count > 0) + { + var reasons = new List(); + if (missingRequired.Count > 0) + { + reasons.Add($"Missing required: {string.Join(", ", missingRequired)}"); + } + + if (missingOptional.Count > 0) + { + reasons.Add($"Missing optional (would cause console prompt hang): {string.Join(", ", missingOptional)}"); + } + + var skipReason = string.Join("; ", reasons); + skipped.Add((sample.Name, skipReason)); + this._reporter.WriteLineWithPrefix(sample.Name, $"SKIPPED — {skipReason}", ConsoleColor.Yellow); + + if (this._logWriter is not null) + { + await this._logWriter.WriteSkippedAsync(sample.Name, skipReason); + } + + continue; + } + + runnableSamples.Add(sample); + } + + // Run samples in parallel + var results = new ConcurrentDictionary(); + var semaphore = new SemaphoreSlim(maxParallelism); + + this._reporter.WriteLineWithPrefix( + "runner", $"Running {runnableSamples.Count} samples (max {maxParallelism} parallel)..."); + + try + { + var tasks = runnableSamples.Select(sample => this.RunSingleAsync(sample, results, semaphore)).ToArray(); + await Task.WhenAll(tasks); + } + finally + { + semaphore.Dispose(); + } + + return new RunAllResult(results, skipped, sampleOrder); + } + + private async Task RunSingleAsync( + SampleDefinition sample, + ConcurrentDictionary results, + SemaphoreSlim semaphore) + { + await semaphore.WaitAsync(); + try + { + var log = new List(); + log.Add($"[{sample.Name}] Running..."); + this._reporter.WriteLineWithPrefix(sample.Name, "Running..."); + + var projectPath = Path.Combine(this._dotnetRoot, sample.ProjectPath); + var run = sample.Inputs.Length > 0 + ? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs) + : await SampleRunner.RunAsync(projectPath, this._timeout); + + log.Add($"[{sample.Name}] Completed ({run.Elapsed.TotalSeconds:F1}s, exit={run.ExitCode})"); + this._reporter.WriteLineWithPrefix( + sample.Name, $"Completed ({run.Elapsed.TotalSeconds:F1}s, exit={run.ExitCode}). Verifying..."); + + var result = await this._verifier.VerifyAsync(sample, run); + + if (result.Passed) + { + log.Add($"[{sample.Name}] PASSED"); + this._reporter.WriteLineWithPrefix(sample.Name, "PASSED", ConsoleColor.Green); + } + else + { + log.Add($"[{sample.Name}] FAILED"); + this._reporter.WriteLineWithPrefix(sample.Name, "FAILED", ConsoleColor.Red); + foreach (var failure in result.Failures) + { + log.Add($"[{sample.Name}] ✗ {failure}"); + this._reporter.WriteLineWithPrefix(sample.Name, $" ✗ {failure}", ConsoleColor.Red); + } + } + + if (result.AIReasoning is not null) + { + log.Add($"[{sample.Name}] AI: {result.AIReasoning}"); + this._reporter.WriteLineWithPrefix( + sample.Name, $" AI: {Truncate(result.AIReasoning, 300)}", ConsoleColor.DarkGray); + } + + var verificationResult = new VerificationResult + { + SampleName = result.SampleName, + Passed = result.Passed, + Summary = result.Summary, + Failures = result.Failures, + AIReasoning = result.AIReasoning, + Stdout = run.Stdout, + Stderr = run.Stderr, + LogLines = log, + }; + results[sample.Name] = verificationResult; + + if (this._logWriter is not null) + { + await this._logWriter.WriteSampleResultAsync(verificationResult); + } + } + finally + { + semaphore.Release(); + } + } + + private static string Truncate(string text, int maxLength) + => text.Length <= maxLength ? text : text[..maxLength] + "..."; +} diff --git a/dotnet/eng/verify-samples/VerificationResult.cs b/dotnet/eng/verify-samples/VerificationResult.cs new file mode 100644 index 0000000000..50a08f969e --- /dev/null +++ b/dotnet/eng/verify-samples/VerificationResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// The result of verifying a single sample. +/// +internal sealed class VerificationResult +{ + public required string SampleName { get; init; } + public required bool Passed { get; init; } + public required string Summary { get; init; } + public List Failures { get; init; } = []; + public string? AIReasoning { get; init; } + + /// + /// The sample's stdout output, captured for log file output. + /// + public string? Stdout { get; init; } + + /// + /// The sample's stderr output, captured for log file output. + /// + public string? Stderr { get; init; } + + /// + /// Per-sample log lines, buffered during parallel execution + /// and written sequentially to the log file. + /// + public List LogLines { get; init; } = []; +} diff --git a/dotnet/eng/verify-samples/VerifyOptions.cs b/dotnet/eng/verify-samples/VerifyOptions.cs new file mode 100644 index 0000000000..78ba38acf1 --- /dev/null +++ b/dotnet/eng/verify-samples/VerifyOptions.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Parsed command-line options for the sample verification tool. +/// +internal sealed class VerifyOptions +{ + /// + /// Maximum number of samples to run concurrently. + /// + public int MaxParallelism { get; init; } = 8; + + /// + /// Path to write a CSV summary file, or null to skip. + /// + public string? CsvFilePath { get; init; } + + /// + /// Path to write a Markdown summary file, or null to skip. + /// + public string? MarkdownFilePath { get; init; } + + /// + /// Path to write a sequential log file, or null to skip. + /// + public string? LogFilePath { get; init; } + + /// + /// The filtered list of samples to process. + /// + public required IReadOnlyList Samples { get; init; } + + /// + /// All known sample set registries, keyed by category name. + /// + private static readonly Dictionary> s_sampleSets = + new(StringComparer.OrdinalIgnoreCase) + { + ["01-get-started"] = GetStartedSamples.All, + ["02-agents"] = AgentsSamples.All, + ["03-workflows"] = WorkflowSamples.All, + }; + + /// + /// Parses command-line arguments and resolves the sample list. + /// Returns null and writes to stderr if the arguments are invalid. + /// + public static VerifyOptions? Parse(string[] args) + { + var argList = args.ToList(); + + var categoryFilter = ExtractArg(argList, "--category"); + var logFilePath = ExtractArg(argList, "--log"); + var csvFilePath = ExtractArg(argList, "--csv"); + var markdownFilePath = ExtractArg(argList, "--md"); + + int maxParallelism = 8; + var parallelArg = ExtractArg(argList, "--parallel"); + if (parallelArg is not null && int.TryParse(parallelArg, out var p) && p > 0) + { + maxParallelism = p; + } + + HashSet? nameFilter = null; + if (argList.Count > 0) + { + nameFilter = argList.ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + // Build the sample list + IReadOnlyList samples; + if (categoryFilter is not null) + { + if (!s_sampleSets.TryGetValue(categoryFilter, out var categoryList)) + { + Console.Error.WriteLine( + $"Unknown category '{categoryFilter}'. Available: {string.Join(", ", s_sampleSets.Keys)}"); + return null; + } + + samples = categoryList; + } + else + { + samples = s_sampleSets.Values.SelectMany(s => s).ToList(); + } + + if (nameFilter is not null) + { + samples = samples.Where(s => nameFilter.Contains(s.Name)).ToList(); + } + + if (samples.Count == 0) + { + var allNames = s_sampleSets.Values.SelectMany(s => s).Select(s => s.Name); + Console.Error.WriteLine($"No matching samples found. Available: {string.Join(", ", allNames)}"); + return null; + } + + return new VerifyOptions + { + MaxParallelism = maxParallelism, + LogFilePath = logFilePath, + CsvFilePath = csvFilePath, + MarkdownFilePath = markdownFilePath, + Samples = samples, + }; + } + + private static string? ExtractArg(List list, string flag) + { + var idx = list.IndexOf(flag); + if (idx < 0) + { + return null; + } + + if (idx + 1 >= list.Count) + { + Console.Error.WriteLine($"Missing value for {flag}."); + list.RemoveAt(idx); + return null; + } + + var value = list[idx + 1]; + list.RemoveRange(idx, 2); + return value; + } +} diff --git a/dotnet/eng/verify-samples/WorkflowSamples.cs b/dotnet/eng/verify-samples/WorkflowSamples.cs new file mode 100644 index 0000000000..2842f4af89 --- /dev/null +++ b/dotnet/eng/verify-samples/WorkflowSamples.cs @@ -0,0 +1,525 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Defines the expected behavior for each sample in 03-workflows. +/// +internal static class WorkflowSamples +{ + public static IReadOnlyList All { get; } = + [ + // ─────────────────────────────────────────────────────────────────── + // _StartHere + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_StartHere_01_Streaming", + ProjectPath = "samples/03-workflows/_StartHere/01_Streaming", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "UppercaseExecutor: HELLO, WORLD!", + "ReverseTextExecutor: !DLROW ,OLLEH", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_02_AgentsInWorkflows", + ProjectPath = "samples/03-workflows/_StartHere/02_AgentsInWorkflows", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show agent responses from a translation workflow.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_03_AgentWorkflowPatterns", + ProjectPath = "samples/03-workflows/_StartHere/03_AgentWorkflowPatterns", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["sequential"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should show a sequential workflow pattern with multiple agents executing tasks in order.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_04_MultiModelService", + ProjectPath = "samples/03-workflows/_StartHere/04_MultiModelService", + RequiredEnvironmentVariables = ["BEDROCK_ACCESS_KEY", "BEDROCK_SECRET_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"], + SkipReason = "Requires multiple external provider API keys (Bedrock, Anthropic, OpenAI).", + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_05_SubWorkflows", + ProjectPath = "samples/03-workflows/_StartHere/05_SubWorkflows", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "=== Sub-Workflow Demonstration ===", + "Final Output:", + "=== Main Workflow Completed ===", + "Sample Complete: Workflows can be composed hierarchically using sub-workflows", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_06_MixedWorkflowAgentsAndExecutors", + ProjectPath = "samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["What is 2 plus 2?"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should show agents and executors working together to process a user question.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_07_WriterCriticWorkflow", + ProjectPath = "samples/03-workflows/_StartHere/07_WriterCriticWorkflow", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["=== Writer-Critic Iteration Workflow ==="], + ExpectedOutputDescription = + [ + "The output should show a writer-critic iteration workflow with writer and critic sections.", + "The critic should either approve or request revisions.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Agents + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Agents_CustomAgentExecutors", + ProjectPath = "samples/03-workflows/Agents/CustomAgentExecutors", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show custom workflow events including slogan generation and feedback.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Agents_FoundryAgent", + ProjectPath = "samples/03-workflows/Agents/FoundryAgent", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + SkipReason = "Requires Azure AI Foundry project endpoint.", + }, + + new SampleDefinition + { + Name = "Workflow_Agents_GroupChatToolApproval", + ProjectPath = "samples/03-workflows/Agents/GroupChatToolApproval", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["Starting group chat workflow for software deployment..."], + ExpectedOutputDescription = + [ + "The output should show a group chat workflow with QA and DevOps agents for software deployment.", + "There should be approval requests for tool calls.", + "The workflow should show interaction between QA and DevOps agents toward deployment.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Agents_WorkflowAsAnAgent", + ProjectPath = "samples/03-workflows/Agents/WorkflowAsAnAgent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["hello", "exit"], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should show a conversational workflow responding to the user's hello message.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Checkpoint + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Checkpoint_CheckpointAndRehydrate", + ProjectPath = "samples/03-workflows/Checkpoint/CheckpointAndRehydrate", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Workflow completed with result:", + "Number of checkpoints created:", + "Hydrating a new workflow instance from the 6th checkpoint.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Checkpoint_CheckpointAndResume", + ProjectPath = "samples/03-workflows/Checkpoint/CheckpointAndResume", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Workflow completed with result:", + "Number of checkpoints created:", + "Restoring from the 6th checkpoint.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Checkpoint_CheckpointWithHumanInTheLoop", + ProjectPath = "samples/03-workflows/Checkpoint/CheckpointWithHumanInTheLoop", + RequiredEnvironmentVariables = [], + Inputs = ["50", "25", "40", "45", "42", "50", "25", "40", "45", "42"], + InputDelayMs = 1000, + MustContain = ["found in"], + ExpectedOutputDescription = + [ + "The output should show a number guessing game with higher/lower hints that eventually reaches the correct number.", + "The output should demonstrate checkpoint save and restore behavior.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Concurrent + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Concurrent_Concurrent", + ProjectPath = "samples/03-workflows/Concurrent/Concurrent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show results from concurrent agent processing.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Concurrent_MapReduce", + ProjectPath = "samples/03-workflows/Concurrent/MapReduce", + RequiredEnvironmentVariables = [], + MustContain = + [ + "=== RUNNING WORKFLOW ===", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // ConditionalEdges + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_ConditionalEdges_01_EdgeCondition", + ProjectPath = "samples/03-workflows/ConditionalEdges/01_EdgeCondition", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an email being classified as spam or not spam and processed accordingly.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_ConditionalEdges_02_SwitchCase", + ProjectPath = "samples/03-workflows/ConditionalEdges/02_SwitchCase", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an ambiguous email being classified as spam, not spam, or uncertain.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_ConditionalEdges_03_MultiSelection", + ProjectPath = "samples/03-workflows/ConditionalEdges/03_MultiSelection", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an email being classified and potentially routed to multiple handlers.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // HumanInTheLoop + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_HumanInTheLoop_Basic", + ProjectPath = "samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic", + RequiredEnvironmentVariables = [], + Inputs = ["50", "25", "40", "45", "42"], + InputDelayMs = 1000, + MustContain = ["found in"], + ExpectedOutputDescription = + [ + "The output should show a number guessing game with higher/lower hints that eventually reaches the correct number 42.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Loop + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Loop", + ProjectPath = "samples/03-workflows/Loop", + RequiredEnvironmentVariables = [], + MustContain = ["Result:"], + }, + + // ─────────────────────────────────────────────────────────────────── + // SharedStates + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_SharedStates", + ProjectPath = "samples/03-workflows/SharedStates", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Total Paragraphs:", + "Total Words:", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Visualization + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Visualization", + ProjectPath = "samples/03-workflows/Visualization", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Generating workflow visualization...", + "Mermaid string:", + "DiGraph string:", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Observability + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Observability_ApplicationInsights", + ProjectPath = "samples/03-workflows/Observability/ApplicationInsights", + RequiredEnvironmentVariables = ["APPLICATIONINSIGHTS_CONNECTION_STRING"], + SkipReason = "Requires Application Insights connection string.", + }, + + new SampleDefinition + { + Name = "Workflow_Observability_AspireDashboard", + ProjectPath = "samples/03-workflows/Observability/AspireDashboard", + RequiredEnvironmentVariables = [], + SkipReason = "Requires Aspire Dashboard / OTLP endpoint.", + }, + + new SampleDefinition + { + Name = "Workflow_Observability_WorkflowAsAnAgent", + ProjectPath = "samples/03-workflows/Observability/WorkflowAsAnAgent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Interactive console with ReadLine loop; requires OTLP endpoint.", + }, + + // ─────────────────────────────────────────────────────────────────── + // Declarative + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Declarative_ConfirmInput", + ProjectPath = "samples/03-workflows/Declarative/ConfirmInput", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + Inputs = ["hello", "hello"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a confirmation prompt and a user response."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_CustomerSupport", + ProjectPath = "samples/03-workflows/Declarative/CustomerSupport", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["My laptop won't start"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a customer support workflow processing a laptop issue, with agent responses providing troubleshooting or support."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_DeepResearch", + ProjectPath = "samples/03-workflows/Declarative/DeepResearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + SkipReason = "Requires external weather API (wttr.in).", + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_ExecuteCode", + ProjectPath = "samples/03-workflows/Declarative/ExecuteCode", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + Inputs = ["What is 12 * 34?"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should show a declarative workflow executing generated code, processing a math question and producing a result."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_ExecuteWorkflow", + ProjectPath = "samples/03-workflows/Declarative/ExecuteWorkflow", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + SkipReason = "Requires a workflow file path as a CLI argument.", + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_FunctionTools", + ProjectPath = "samples/03-workflows/Declarative/FunctionTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What are today's specials?", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow calling function tools (e.g. a menu plugin) to answer a question about restaurant specials."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_GenerateCode", + ProjectPath = "samples/03-workflows/Declarative/GenerateCode", + IsDeterministic = true, + MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"], + ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_HostedWorkflow", + ProjectPath = "samples/03-workflows/Declarative/HostedWorkflow", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + SkipReason = "Hosts a persistent workflow server that does not exit.", + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_InputArguments", + ProjectPath = "samples/03-workflows/Declarative/InputArguments", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["I'd like to visit Seattle", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow capturing location input and providing travel-related information about Seattle."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_InvokeFunctionTool", + ProjectPath = "samples/03-workflows/Declarative/InvokeFunctionTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What's the soup of the day?", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_InvokeMcpTool", + ProjectPath = "samples/03-workflows/Declarative/InvokeMcpTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Search for .NET tutorials on Microsoft Learn"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a workflow using MCP tools to search Microsoft Learn documentation and provide a summary of results."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_Marketing", + ProjectPath = "samples/03-workflows/Declarative/Marketing", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["A smart water bottle that tracks hydration"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a marketing workflow generating content about a smart water bottle product."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_StudentTeacher", + ProjectPath = "samples/03-workflows/Declarative/StudentTeacher", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What is 18 + 27?"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a student-teacher workflow where a student asks a math question and a teacher provides the answer."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_ToolApproval", + ProjectPath = "samples/03-workflows/Declarative/ToolApproval", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Search for .NET tutorials", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow using an MCP tool with approval to search Microsoft Learn, followed by an exit from the input loop."], + }, + ]; +} diff --git a/dotnet/eng/verify-samples/verify-samples.csproj b/dotnet/eng/verify-samples/verify-samples.csproj new file mode 100644 index 0000000000..f7f86ba90d --- /dev/null +++ b/dotnet/eng/verify-samples/verify-samples.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + false + + $(NoWarn);CA2007 + + + + + + + + + + + + + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index f2f4b59fc0..c64f43949f 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,19 +2,20 @@ 1.0.0 - 5 + 6 $(VersionPrefix)-rc$(RCNumber) - $(VersionPrefix)-$(VersionSuffix).260330.1 - $(VersionPrefix)-preview.260330.1 - 1.0.0-rc5 + $(VersionPrefix)-$(VersionSuffix).260402.1 + $(VersionPrefix)-preview.260402.1 + $(VersionPrefix) + 1.0.0 Debug;Release;Publish true - 1.0.0-rc4 + 1.0.0-rc5 - true + true $(NoWarn);CP0003 diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs index 8e1f4245b6..83445dc1fc 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs @@ -23,7 +23,7 @@ const string SourceName = "OpenTelemetryAspire.ConsoleApp"; const string ServiceName = "AgentOpenTelemetry"; // Configure OpenTelemetry for Aspire dashboard -var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4318"; +var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4317"; var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md index c1a569874b..3be31187e0 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md @@ -5,8 +5,8 @@ This sample demonstrates how to create an AIAgent using Anthropic Claude models The sample supports three deployment scenarios: 1. **Anthropic Public API** - Direct connection to Anthropic's public API -2. **Azure Foundry with API Key** - Anthropic models deployed through Azure Foundry using API key authentication -3. **Azure Foundry with Azure CLI** - Anthropic models deployed through Azure Foundry using Azure CLI credentials +2. **Microsoft Foundry with API Key** - Anthropic models deployed through Microsoft Foundry using API key authentication +3. **Microsoft Foundry with Azure CLI** - Anthropic models deployed through Microsoft Foundry using Azure CLI credentials ## Prerequisites @@ -25,29 +25,29 @@ $env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic A $env:ANTHROPIC_CHAT_MODEL_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 ``` -### For Azure Foundry with API Key +### For Microsoft Foundry with API Key -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Anthropic API key Set the following environment variables: ```powershell -$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Azure Foundry resource name (subdomain before .services.ai.azure.com) +$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Microsoft Foundry resource name (subdomain before .services.ai.azure.com) $env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key $env:ANTHROPIC_CHAT_MODEL_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 ``` -### For Azure Foundry with Azure CLI +### For Microsoft Foundry with Azure CLI -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) Set the following environment variables: ```powershell -$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Azure Foundry resource name (subdomain before .services.ai.azure.com) +$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Microsoft Foundry resource name (subdomain before .services.ai.azure.com) $env:ANTHROPIC_CHAT_MODEL_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 ``` -**Note**: When using Azure Foundry with Azure CLI, make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: When using Microsoft Foundry with Azure CLI, make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs index 0603933dbf..a4b702be2c 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs @@ -2,7 +2,7 @@ #pragma warning disable CS0618 // Type or member is obsolete - sample uses deprecated PersistentAgentsClientExtensions -// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend. +// This sample shows how to create and use a simple AI agent with Microsoft Foundry Agents as the backend. using Azure.AI.Agents.Persistent; using Azure.Identity; diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md index 969795d87f..7c92d809d3 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md @@ -13,14 +13,14 @@ Below is a comparison between the classic and new Foundry Agents approaches: Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj index a8deaa57b5..562ce0c37e 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj @@ -15,7 +15,7 @@
- + diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs index b2cd14f68a..f5321c4350 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -1,29 +1,29 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a AI agents with Azure Foundry Agents as the backend. +// This sample shows how to create and use AI agents with Microsoft Foundry Agents as the backend. using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; const string JokerName = "JokerAgent"; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// Get a client to create/retrieve/delete server side agents with Microsoft Foundry Agents. // 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. var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent you want to create. (Prompt Agent in this case) -var agentVersionCreationOptions = new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); +var agentVersionCreationOptions = new ProjectsAgentVersionCreationOptions(new DeclarativeAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); // Azure.AI.Agents SDK creates and manages agent by name and versions. // You can create a server side agent version with the Azure.AI.Agents SDK client below. -var createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options: agentVersionCreationOptions); +var createdAgentVersion = aiProjectClient.AgentAdministrationClient.CreateAgentVersion(agentName: JokerName, options: agentVersionCreationOptions); // Note: // agentVersion.Id = ":", @@ -34,15 +34,15 @@ var createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: J FoundryAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion); // You can also create another AIAgent version by providing the same name with a different definition. -AgentVersion newJokerAgentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( +ProjectsAgentVersion newJokerAgentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( JokerName, - new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are extremely hilarious at telling jokes." })); + new ProjectsAgentVersionCreationOptions(new DeclarativeAgentDefinition(model: deploymentName) { Instructions = "You are extremely hilarious at telling jokes." })); FoundryAgent newJokerAgent = aiProjectClient.AsAIAgent(newJokerAgentVersion); // You can also get the AIAgent latest version just providing its name. -AgentRecord jokerAgentRecord = await aiProjectClient.Agents.GetAgentAsync(JokerName); +ProjectsAgentRecord jokerAgentRecord = await aiProjectClient.AgentAdministrationClient.GetAgentAsync(JokerName); FoundryAgent jokerAgentLatest = aiProjectClient.AsAIAgent(jokerAgentRecord); -AgentVersion latestAgentVersion = jokerAgentRecord.GetLatestVersion(); +ProjectsAgentVersion latestAgentVersion = jokerAgentRecord.GetLatestVersion(); // The AIAgent version can be accessed via the GetService method. Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}"); @@ -55,4 +55,4 @@ Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate Console.WriteLine(await jokerAgentLatest.RunAsync("Now tell me a joke about a cat and a dog using last joke as the anchor.", session)); // Cleanup by agent name removes both agent versions created. -aiProjectClient.Agents.DeleteAgent(existingJokerAgent.Name); +aiProjectClient.AgentAdministrationClient.DeleteAgent(existingJokerAgent.Name); diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md index 66fcbf8297..ec688df59d 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md @@ -13,14 +13,14 @@ Below is a comparison between the classic and new Foundry Agents approaches: Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs index fe682d388a..556b52bf17 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Azure AI Foundry. -// You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in your Azure AI Foundry resource. +// This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Microsoft Foundry. +// You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in your Microsoft Foundry resource. // Note: Ensure that you pick a model that suits your needs. For example, if you want to use function calling, ensure that the model you pick supports function calling. using System.ClientModel; @@ -15,7 +15,7 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "Phi-4-mini-instruct"; -// Since we are using the OpenAI Client SDK, we need to override the default endpoint to point to Azure Foundry. +// Since we are using the OpenAI Client SDK, we need to override the default endpoint to point to Microsoft Foundry. var clientOptions = new OpenAIClientOptions() { Endpoint = new Uri(endpoint) }; // Create the OpenAI client with either an API key or Azure CLI credential. diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md index 6d5b6badd7..9bc4d60881 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md @@ -1,8 +1,8 @@ ## Overview -This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Azure AI Foundry. +This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Microsoft Foundry. -You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in Azure AI Foundry. +You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in Microsoft Foundry. **Note**: Ensure that you pick a model that suits your needs. For example, if you want to use function calling, ensure that the model you pick supports function calling. @@ -11,19 +11,19 @@ You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI o Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure AI Foundry resource -- A model deployment in your Azure AI Foundry resource. This example defaults to using the `Phi-4-mini-instruct` model, +- Microsoft Foundry resource +- A model deployment in your Microsoft Foundry resource. This example defaults to using the `Phi-4-mini-instruct` model, so if you want to use a different model, ensure that you set your `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable to the name of your deployed model. -- An API key or role based authentication to access the Azure AI Foundry resource +- An API key or role based authentication to access the Microsoft Foundry resource See [here](https://learn.microsoft.com/en-us/azure/ai-foundry/quickstarts/get-started-code?tabs=csharp) for more info on setting up these prerequisites Set the following environment variables: ```powershell -# Replace with your Azure AI Foundry resource endpoint -# Ensure that you have the "/openai/v1/" path in the URL, since this is required when using the OpenAI SDK to access Azure Foundry models. +# Replace with your Microsoft Foundry resource endpoint +# Ensure that you have the "/openai/v1/" path in the URL, since this is required when using the OpenAI SDK to access Microsoft Foundry models. $env:AZURE_OPENAI_ENDPOINT="https://ai-foundry-.services.ai.azure.com/openai/v1/" # Optional, defaults to using Azure CLI for authentication if not provided diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/Program.cs deleted file mode 100644 index 02d19ab52c..0000000000 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/Program.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to create and use a simple AI agent with OpenAI Assistants as the backend. - -// WARNING: The Assistants API is deprecated and will be shut down. -// For more information see the OpenAI documentation: https://platform.openai.com/docs/assistants/migration - -#pragma warning disable CS0618 // Type or member is obsolete - OpenAI Assistants API is deprecated but still used in this sample - -using Microsoft.Agents.AI; -using OpenAI; -using OpenAI.Assistants; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; - -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - -// Get a client to create/retrieve server side agents with. -var assistantClient = new OpenAIClient(apiKey).GetAssistantClient(); - -// You can create a server side assistant with the OpenAI SDK. -var createResult = await assistantClient.CreateAssistantAsync(model, new() { Name = JokerName, Instructions = JokerInstructions }); - -// You can retrieve an already created server side assistant as an AIAgent. -AIAgent agent1 = await assistantClient.GetAIAgentAsync(createResult.Value.Id); - -// You can also create a server side assistant and return it as an AIAgent directly. -AIAgent agent2 = await assistantClient.CreateAIAgentAsync( - model: model, - name: JokerName, - instructions: JokerInstructions); - -// You can invoke the agent like any other AIAgent. -AgentSession session = await agent1.CreateSessionAsync(); -Console.WriteLine(await agent1.RunAsync("Tell me a joke about a pirate.", session)); - -// Cleanup for sample purposes. -await assistantClient.DeleteAssistantAsync(agent1.Id); -await assistantClient.DeleteAssistantAsync(agent2.Id); diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/README.md deleted file mode 100644 index b0a7638ab5..0000000000 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Prerequisites - -WARNING: The Assistants API is deprecated and will be shut down. -For more information see the OpenAI documentation: https://platform.openai.com/docs/assistants/migration - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- OpenAI API key - -Set the following environment variables: - -```powershell -$env:OPENAI_API_KEY="*****" # Replace with your OpenAI API key -$env:OPENAI_CHAT_MODEL_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` diff --git a/dotnet/samples/02-agents/AgentProviders/README.md b/dotnet/samples/02-agents/AgentProviders/README.md index 071722d50d..5584fdc810 100644 --- a/dotnet/samples/02-agents/AgentProviders/README.md +++ b/dotnet/samples/02-agents/AgentProviders/README.md @@ -18,14 +18,13 @@ See the README.md for each sample for the prerequisites for that sample. |[Creating an AIAgent with Anthropic](./Agent_With_Anthropic/)|This sample demonstrates how to create an AIAgent using Anthropic Claude models as the underlying inference service| |[Creating an AIAgent with Foundry Agents using Azure.AI.Agents.Persistent](./Agent_With_AzureAIAgentsPersistent/)|This sample demonstrates how to create a Foundry Persistent agent and expose it as an AIAgent using the Azure.AI.Agents.Persistent SDK| |[Creating an AIAgent with Foundry Agents using Azure.AI.Project](./Agent_With_AzureAIProject/)|This sample demonstrates how to create an Foundry Project agent and expose it as an AIAgent using the Azure.AI.Project SDK| -|[Creating an AIAgent with AzureFoundry Model](./Agent_With_AzureFoundryModel/)|This sample demonstrates how to use any model deployed to Azure Foundry to create an AIAgent| +|[Creating an AIAgent with Foundry Model](./Agent_With_AzureFoundryModel/)|This sample demonstrates how to use any model deployed to Microsoft Foundry to create an AIAgent| |[Creating an AIAgent with Azure OpenAI ChatCompletion](./Agent_With_AzureOpenAIChatCompletion/)|This sample demonstrates how to create an AIAgent using Azure OpenAI ChatCompletion as the underlying inference service| |[Creating an AIAgent with Azure OpenAI Responses](./Agent_With_AzureOpenAIResponses/)|This sample demonstrates how to create an AIAgent using Azure OpenAI Responses as the underlying inference service| |[Creating an AIAgent with a custom implementation](./Agent_With_CustomImplementation/)|This sample demonstrates how to create an AIAgent with a custom implementation| |[Creating an AIAgent with GitHub Copilot](./Agent_With_GitHubCopilot/)|This sample demonstrates how to create an AIAgent using GitHub Copilot SDK as the underlying inference service| |[Creating an AIAgent with Ollama](./Agent_With_Ollama/)|This sample demonstrates how to create an AIAgent using Ollama as the underlying inference service| |[Creating an AIAgent with ONNX](./Agent_With_ONNX/)|This sample demonstrates how to create an AIAgent using ONNX as the underlying inference service| -|[Creating an AIAgent with OpenAI Assistants](./Agent_With_OpenAIAssistants/)|This sample demonstrates how to create an AIAgent using OpenAI Assistants as the underlying inference service.
WARNING: The Assistants API is deprecated and will be shut down. For more information see the OpenAI documentation: https://platform.openai.com/docs/assistants/migration| |[Creating an AIAgent with OpenAI ChatCompletion](./Agent_With_OpenAIChatCompletion/)|This sample demonstrates how to create an AIAgent using OpenAI ChatCompletion as the underlying inference service| |[Creating an AIAgent with OpenAI Responses](./Agent_With_OpenAIResponses/)|This sample demonstrates how to create an AIAgent using OpenAI Responses as the underlying inference service| diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md index 41b813b98f..592aca4a27 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -6,7 +6,7 @@ This sample demonstrates how to use **file-based Agent Skills** with a `ChatClie - Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource` - The progressive disclosure pattern: advertise → load → read resources → run scripts -- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor +- Using the `AgentSkillsProvider` constructor with a skill directory path and script runner - Running file-based scripts (Python) via a subprocess-based executor ## Skills Included diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj similarity index 69% rename from dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj rename to dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj index eeda3eef6f..fd3d71fe7e 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj @@ -6,8 +6,14 @@ enable enable + $(NoWarn);MAAI001 + + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs new file mode 100644 index 0000000000..fb0f202230 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill. +// Class-based skills bundle all components into a single class implementation. + +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Class-Based Skill --- +// Instantiate the skill class. +var unitConverter = new UnitConverterSkill(); + +// --- Skills Provider --- +var skillsProvider = new AgentSkillsProvider(unitConverter); + +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Unit conversion --- +Console.WriteLine("Converting units with class-based skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); + +/// +/// A unit-converter skill defined as a C# class. +/// +/// +/// Class-based skills bundle all components (name, description, body, resources, scripts) +/// into a single class. +/// +internal sealed class UnitConverterSkill : AgentClassSkill +{ + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + /// + public override AgentSkillFrontmatter Frontmatter { get; } = new( + "unit-converter", + "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms."); + + /// + protected override string Instructions => """ + Use this skill when the user asks to convert between units. + + 1. Review the conversion-table resource to find the factor for the requested conversion. + 2. Use the convert script, passing the value and factor from the table. + 3. Present the result clearly with both units. + """; + + /// + public override IReadOnlyList? Resources => this._resources ??= + [ + CreateResource( + "conversion-table", + """ + # Conversion Tables + + Formula: **result = value × factor** + + | From | To | Factor | + |-------------|-------------|----------| + | miles | kilometers | 1.60934 | + | kilometers | miles | 0.621371 | + | pounds | kilograms | 0.453592 | + | kilograms | pounds | 2.20462 | + """), + ]; + + /// + public override IReadOnlyList? Scripts => this._scripts ??= + [ + CreateScript("convert", ConvertUnits), + ]; + + private static string ConvertUnits(double value, double factor) + { + double result = Math.Round(value * factor, 4); + return JsonSerializer.Serialize(new { value, factor, result }); + } +} diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md new file mode 100644 index 0000000000..506784256a --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md @@ -0,0 +1,49 @@ +# Class-Based Agent Skills Sample + +This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`. + +## What it demonstrates + +- Creating skills as classes that extend `AgentClassSkill` +- Bundling name, description, body, resources, and scripts into a single class +- Using the `AgentSkillsProvider` constructor with class-based skills + +## Skills Included + +### unit-converter (class-based) + +A `UnitConverterSkill` class that converts between common units. Defined in `Program.cs`: + +- `conversion-table` — Static resource with factor table +- `convert` — Script that performs `value × factor` conversion + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with class-based skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj new file mode 100644 index 0000000000..7e7e9ef0fa --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj @@ -0,0 +1,32 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);MAAI001 + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs new file mode 100644 index 0000000000..b8a9e8fbb1 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates an advanced scenario: combining multiple skill types in a single agent +// using AgentSkillsProviderBuilder. The builder is designed for cases where the simple +// AgentSkillsProvider constructors are insufficient — for example, when you need to mix skill +// sources, apply filtering, or configure cross-cutting options in one place. +// +// Three different skill sources are registered here: +// 1. File-based: unit-converter (miles↔km, pounds↔kg) from SKILL.md on disk +// 2. Code-defined: volume-converter (gallons↔liters) using AgentInlineSkill +// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill +// +// For simpler, single-source scenarios, see the earlier steps in this sample series +// (e.g., Step01 for file-based, Step02 for code-defined, Step03 for class-based). + +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- 1. Code-Defined Skill: volume-converter --- +var volumeConverterSkill = new AgentInlineSkill( + name: "volume-converter", + description: "Convert between gallons and liters using a multiplication factor.", + instructions: """ + Use this skill when the user asks to convert between gallons and liters. + + 1. Review the volume-conversion-table resource to find the correct factor. + 2. Use the convert-volume script, passing the value and factor. + """) + .AddResource("volume-conversion-table", + """ + # Volume Conversion Table + + Formula: **result = value × factor** + + | From | To | Factor | + |---------|---------|---------| + | gallons | liters | 3.78541 | + | liters | gallons | 0.264172| + """) + .AddScript("convert-volume", (double value, double factor) => + { + double result = Math.Round(value * factor, 4); + return JsonSerializer.Serialize(new { value, factor, result }); + }); + +// --- 2. Class-Based Skill: temperature-converter --- +var temperatureConverter = new TemperatureConverterSkill(); + +// --- 3. Build provider combining all three source types --- +var skillsProvider = new AgentSkillsProviderBuilder() + .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills")) // File-based: unit-converter + .UseSkill(volumeConverterSkill) // Code-defined: volume-converter + .UseSkill(temperatureConverter) // Class-based: temperature-converter + .UseFileScriptRunner(SubprocessScriptRunner.RunAsync) + .Build(); + +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "MultiConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units, volumes, and temperatures.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Use all three skills --- +Console.WriteLine("Converting with mixed skills (file + code + class)"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "I need three conversions: " + + "1) How many kilometers is a marathon (26.2 miles)? " + + "2) How many liters is a 5-gallon bucket? " + + "3) What is 98.6°F in Celsius?"); + +Console.WriteLine($"Agent: {response.Text}"); + +/// +/// A temperature-converter skill defined as a C# class. +/// +internal sealed class TemperatureConverterSkill : AgentClassSkill +{ + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + /// + public override AgentSkillFrontmatter Frontmatter { get; } = new( + "temperature-converter", + "Convert between temperature scales (Fahrenheit, Celsius, Kelvin)."); + + /// + protected override string Instructions => """ + Use this skill when the user asks to convert temperatures. + + 1. Review the temperature-conversion-formulas resource for the correct formula. + 2. Use the convert-temperature script, passing the value, source scale, and target scale. + 3. Present the result clearly with both temperature scales. + """; + + /// + public override IReadOnlyList? Resources => this._resources ??= + [ + CreateResource( + "temperature-conversion-formulas", + """ + # Temperature Conversion Formulas + + | From | To | Formula | + |-------------|-------------|---------------------------| + | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 | + | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 | + | Celsius | Kelvin | K = °C + 273.15 | + | Kelvin | Celsius | °C = K − 273.15 | + """), + ]; + + /// + public override IReadOnlyList? Scripts => this._scripts ??= + [ + CreateScript("convert-temperature", ConvertTemperature), + ]; + + private static string ConvertTemperature(double value, string from, string to) + { + double result = (from.ToUpperInvariant(), to.ToUpperInvariant()) switch + { + ("FAHRENHEIT", "CELSIUS") => Math.Round((value - 32) * 5.0 / 9.0, 2), + ("CELSIUS", "FAHRENHEIT") => Math.Round(value * 9.0 / 5.0 + 32, 2), + ("CELSIUS", "KELVIN") => Math.Round(value + 273.15, 2), + ("KELVIN", "CELSIUS") => Math.Round(value - 273.15, 2), + _ => throw new ArgumentException($"Unsupported conversion: {from} → {to}") + }; + + return JsonSerializer.Serialize(new { value, from, to, result }); + } +} diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md new file mode 100644 index 0000000000..14a0d089b9 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md @@ -0,0 +1,67 @@ +# Mixed Agent Skills Sample (Advanced) + +This sample demonstrates an **advanced scenario**: combining multiple skill types in a single agent using `AgentSkillsProviderBuilder`. + +> **Tip:** For simpler, single-source scenarios, use the `AgentSkillsProvider` constructors directly — see [Step01](../Agent_Step01_FileBasedSkills/) (file-based), [Step02](../Agent_Step02_CodeDefinedSkills/) (code-defined), or [Step03](../Agent_Step03_ClassBasedSkills/) (class-based). + +## What it demonstrates + +- Combining file-based, code-defined, and class-based skills in one provider +- Using `UseFileSkill` and `UseSkill` on the builder to register different skill types +- Aggregating skills from all sources into a single provider with automatic deduplication + +## When to use `AgentSkillsProviderBuilder` + +The builder is intended for advanced scenarios where the simple `AgentSkillsProvider` constructors are insufficient: + +| Scenario | Builder method | +|----------|---------------| +| **Mixed skill types** — combine file-based, code-defined, and class-based skills | `UseFileSkill` + `UseSkill` / `UseSkills` | +| **Multiple file script runners** — use different script runners for different file skill directories | `UseFileSkill` / `UseFileSkills` with per-source `scriptRunner` | +| **Skill filtering** — include/exclude skills using a predicate | `UseFilter(predicate)` | + +## Skills Included + +### unit-converter (file-based) + +Discovered from `skills/unit-converter/SKILL.md` on disk. Converts miles↔km, pounds↔kg. + +### volume-converter (code-defined) + +Defined as `AgentInlineSkill` in `Program.cs`. Converts gallons↔liters. + +### temperature-converter (class-based) + +Defined as `TemperatureConverterSkill` class in `Program.cs`. Converts °F↔°C↔K. + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting with mixed skills (file + code + class) +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **5 gallons → 18.93 liters** +3. **98.6°F → 37.0°C** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/SKILL.md new file mode 100644 index 0000000000..246a3392f7 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/SKILL.md @@ -0,0 +1,11 @@ +--- +name: unit-converter +description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms. +--- + +## Usage + +When the user requests a unit conversion: +1. First, review `references/unit-conversion-table.md` to find the correct factor +2. Run the `scripts/convert-units.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`) +3. Present the converted value clearly with both units diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/references/unit-conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/references/unit-conversion-table.md new file mode 100644 index 0000000000..7a0160b854 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/references/unit-conversion-table.md @@ -0,0 +1,10 @@ +# Conversion Tables + +Formula: **result = value × factor** + +| From | To | Factor | +|-------------|-------------|----------| +| miles | kilometers | 1.60934 | +| kilometers | miles | 0.621371 | +| pounds | kilograms | 0.453592 | +| kilograms | pounds | 2.20462 | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/scripts/convert-units.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/scripts/convert-units.py new file mode 100644 index 0000000000..ac271dd594 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/skills/unit-converter/scripts/convert-units.py @@ -0,0 +1,29 @@ +# Unit conversion script +# Converts a value using a multiplication factor: result = value × factor +# +# Usage: +# python scripts/convert-units.py --value 26.2 --factor 1.60934 +# python scripts/convert-units.py --value 75 --factor 2.20462 + +import argparse +import json + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert a value using a multiplication factor.", + epilog="Examples:\n" + " python scripts/convert-units.py --value 26.2 --factor 1.60934\n" + " python scripts/convert-units.py --value 75 --factor 2.20462", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") + parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") + args = parser.parse_args() + + result = round(args.value * args.factor, 4) + print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) + + +if __name__ == "__main__": + main() diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj new file mode 100644 index 0000000000..959fa29167 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);MAAI001;CA1812 + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs new file mode 100644 index 0000000000..f0c5a4c8a5 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use Dependency Injection (DI) with Agent Skills. +// It shows two approaches side-by-side, each handling a different conversion domain: +// +// 1. Code-defined skill (AgentInlineSkill) — converts distances (miles ↔ kilometers). +// Resources and scripts are inline delegates that resolve services from IServiceProvider. +// +// 2. Class-based skill (AgentClassSkill) — converts weights (pounds ↔ kilograms). +// Resources and scripts are encapsulated in a class, also resolving services from IServiceProvider. +// +// Both skills share the same ConversionService registered in the DI container, +// showing that DI works identically regardless of how the skill is defined. +// When prompted with a question spanning both domains, the agent uses both skills. + +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- DI Container --- +// Register application services that skill resources and scripts can resolve at execution time. +ServiceCollection services = new(); +services.AddSingleton(); + +IServiceProvider serviceProvider = services.BuildServiceProvider(); + +// ===================================================================== +// Approach 1: Code-Defined Skill with DI (AgentInlineSkill) +// ===================================================================== +// Handles distance conversions (miles ↔ kilometers). +// Resources and scripts are inline delegates. Each delegate can declare +// an IServiceProvider parameter that the framework injects automatically. + +var distanceSkill = new AgentInlineSkill( + name: "distance-converter", + description: "Convert between distance units. Use when asked to convert miles to kilometers or kilometers to miles.", + instructions: """ + Use this skill when the user asks to convert between distance units (miles and kilometers). + + 1. Review the distance-table resource to find the factor for the requested conversion. + 2. Use the convert script, passing the value and factor from the table. + """) + .AddResource("distance-table", (IServiceProvider serviceProvider) => + { + var service = serviceProvider.GetRequiredService(); + return service.GetDistanceTable(); + }) + .AddScript("convert", (double value, double factor, IServiceProvider serviceProvider) => + { + var service = serviceProvider.GetRequiredService(); + return service.Convert(value, factor); + }); + +// ===================================================================== +// Approach 2: Class-Based Skill with DI (AgentClassSkill) +// ===================================================================== +// Handles weight conversions (pounds ↔ kilograms). +// Resources and scripts are encapsulated in a class. Factory methods +// CreateResource and CreateScript accept delegates with IServiceProvider. +// +// Alternatively, class-based skills can accept dependencies through their +// constructor. Register the skill class itself in the ServiceCollection and +// resolve it from the container: +// +// services.AddSingleton(); +// var weightSkill = serviceProvider.GetRequiredService(); + +var weightSkill = new WeightConverterSkill(); + +// --- Skills Provider --- +// Both skills are registered with the same provider so the agent can use either one. +var skillsProvider = new AgentSkillsProvider(distanceSkill, weightSkill); + +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent( + options: new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName, + services: serviceProvider); + +// --- Example: Unit conversion --- +// This prompt spans both domains, so the agent will use both skills. +Console.WriteLine("Converting units with DI-powered skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); + +// --------------------------------------------------------------------------- +// Class-Based Skill +// --------------------------------------------------------------------------- + +/// +/// A weight-converter skill defined as a C# class that uses Dependency Injection. +/// +/// +/// This skill resolves from the DI container +/// in both its resource and script functions. This enables clean separation of +/// concerns and testability while retaining the class-based skill pattern. +/// +internal sealed class WeightConverterSkill : AgentClassSkill +{ + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + /// + public override AgentSkillFrontmatter Frontmatter { get; } = new( + "weight-converter", + "Convert between weight units. Use when asked to convert pounds to kilograms or kilograms to pounds."); + + /// + protected override string Instructions => """ + Use this skill when the user asks to convert between weight units (pounds and kilograms). + + 1. Review the weight-table resource to find the factor for the requested conversion. + 2. Use the convert script, passing the value and factor from the table. + 3. Present the result clearly with both units. + """; + + /// + public override IReadOnlyList? Resources => this._resources ??= + [ + CreateResource("weight-table", (IServiceProvider serviceProvider) => + { + var service = serviceProvider.GetRequiredService(); + return service.GetWeightTable(); + }), + ]; + + /// + public override IReadOnlyList? Scripts => this._scripts ??= + [ + CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) => + { + var service = serviceProvider.GetRequiredService(); + return service.Convert(value, factor); + }), + ]; +} + +// --------------------------------------------------------------------------- +// Services +// --------------------------------------------------------------------------- + +/// +/// Provides conversion rates between units. +/// In a real application this could call an external API, read from a database, +/// or apply time-varying exchange rates. +/// +internal sealed class ConversionService +{ + /// + /// Returns a markdown table of supported distance conversions. + /// + public string GetDistanceTable() => + """ + # Distance Conversions + + Formula: **result = value × factor** + + | From | To | Factor | + |-------------|-------------|----------| + | miles | kilometers | 1.60934 | + | kilometers | miles | 0.621371 | + """; + + /// + /// Returns a markdown table of supported weight conversions. + /// + public string GetWeightTable() => + """ + # Weight Conversions + + Formula: **result = value × factor** + + | From | To | Factor | + |-------------|-------------|----------| + | pounds | kilograms | 0.453592 | + | kilograms | pounds | 2.20462 | + """; + + /// + /// Converts a value by the given factor and returns a JSON result. + /// + public string Convert(double value, double factor) + { + double result = Math.Round(value * factor, 4); + return JsonSerializer.Serialize(new { value, factor, result }); + } +} diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/README.md new file mode 100644 index 0000000000..b284b745d5 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/README.md @@ -0,0 +1,65 @@ +# Agent Skills with Dependency Injection + +This sample demonstrates how to use **Dependency Injection (DI)** with Agent Skills. It shows two approaches side-by-side, each handling a different conversion domain: + +1. **Code-defined skill** (`AgentInlineSkill`) — converts **distances** (miles ↔ kilometers) +2. **Class-based skill** (`AgentClassSkill`) — converts **weights** (pounds ↔ kilograms) + +Both skills resolve the same `ConversionService` from the DI container. When prompted with a question spanning both domains, the agent uses both skills. + +## What It Shows + +- Registering application services in a `ServiceCollection` +- Defining a **code-defined** skill (distance converter) with resources and scripts that resolve services from `IServiceProvider` +- Defining a **class-based** skill (weight converter) with resources and scripts that resolve services from `IServiceProvider` +- Passing the built `IServiceProvider` to the agent so skills can access DI services at execution time +- Running a single prompt that exercises both skills to show they work together + +## How It Works + +1. A `ConversionService` is registered as a singleton in the DI container +2. **Code-defined skill**: An `AgentInlineSkill` for distance conversions declares `IServiceProvider` as a parameter in its `AddResource` and `AddScript` delegates — the framework injects it automatically +3. **Class-based skill**: A `WeightConverterSkill` class extends `AgentClassSkill` for weight conversions and uses `CreateResource`/`CreateScript` factory methods with `IServiceProvider` parameters +4. Both skills resolve `ConversionService` from the provider — one for distance tables, the other for weight tables +5. A single agent is created with both skills registered, and the service provider flows through to skill execution + +> **Tip:** Class-based skills can also accept dependencies through their **constructor**. Register the skill class in the `ServiceCollection` and resolve it from the container instead of calling `new` directly. This is useful when the skill itself needs injected services beyond what the resource/script delegates use. + +## How It Differs from Other Samples + +| Sample | Skill Type | DI Support | +|--------|------------|------------| +| [Step02](../Agent_Step02_CodeDefinedSkills/) | Code-defined (`AgentInlineSkill`) | No — static resources | +| [Step03](../Agent_Step03_ClassBasedSkills/) | Class-based (`AgentClassSkill`) | No — static resources | +| **Step05 (this)** | **Both code-defined and class-based** | **Yes — DI via `IServiceProvider`** | + +## Prerequisites + +- .NET 10 +- An Azure OpenAI deployment + +## Configuration + +Set the following environment variables: + +| Variable | Description | +|---|---| +| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name (defaults to `gpt-4o-mini`) | + +## Running the Sample + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with DI-powered skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 6011384997..bbf511da4e 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -6,19 +6,32 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w |--------|-------------| | [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | | [Agent_Step02_CodeDefinedSkills](Agent_Step02_CodeDefinedSkills/) | Define skills entirely in C# code using `AgentInlineSkill`, with static/dynamic resources and scripts. | +| [Agent_Step03_ClassBasedSkills](Agent_Step03_ClassBasedSkills/) | Define skills as C# classes using `AgentClassSkill`. | +| [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) | **(Advanced)** Combine file-based, code-defined, and class-based skills using `AgentSkillsProviderBuilder`. | +| [Agent_Step05_SkillsWithDI](Agent_Step05_SkillsWithDI/) | Use Dependency Injection with both code-defined (`AgentInlineSkill`) and class-based (`AgentClassSkill`) skills. | ## Key Concepts -### File-Based vs Code-Defined Skills +### Skill Types -| Aspect | File-Based | Code-Defined | -|--------|-----------|--------------| -| Definition | `SKILL.md` files on disk | `AgentInlineSkill` instances in C# | -| Resources | All files in skill directory (filtered by extension) | `AddResource` (static value or delegate-backed) | -| Scripts | Supported via script executor delegate | `AddScript` delegates | -| Discovery | Automatic from directory path | Explicit via constructor | -| Dynamic content | No (static files only) | Yes (factory delegates) | -| Reusability | Copy skill directory | Inline or shared instances | +| Aspect | File-Based | Code-Defined | Class-Based | +|--------|-----------|--------------|-------------| +| Definition | `SKILL.md` files on disk | `AgentInlineSkill` instances in C# | Classes extending `AgentClassSkill` | +| Resources | All files in skill directory (filtered by extension) | `AddResource` (static value or delegate-backed) | `CreateResource` factory methods | +| Scripts | Supported via script runner delegate | `AddScript` delegates | `CreateScript` factory methods | +| Discovery | Automatic from directory path | Explicit via constructor | Explicit via constructor | +| Dynamic content | No (static files only) | Yes (factory delegates) | Yes (factory delegates) | +| Sharing pattern | Copy skill directory | Inline or shared instances | Package in shared assemblies/NuGet | +| DI support | No | Yes (via `IServiceProvider` parameter) | Yes (via `IServiceProvider` parameter) | -For single-source scenarios, use the `AgentSkillsProvider` constructors directly. To combine multiple skill types, use the `AgentSkillsProviderBuilder`. +### `AgentSkillsProvider` vs `AgentSkillsProviderBuilder` +For single-source scenarios, use the `AgentSkillsProvider` constructors directly — they accept a skill directory path, a set of skills, or a custom source. + +Use `AgentSkillsProviderBuilder` for advanced scenarios where simple constructors are insufficient: + +- **Mixed skill types** — combine file-based, code-defined, and class-based skills in one provider +- **Multiple file script runners** — use different script runners for different file skill directories +- **Skill filtering** — include or exclude skills using a predicate + +See [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) for a working example. diff --git a/dotnet/samples/02-agents/AgentWithAnthropic/README.md b/dotnet/samples/02-agents/AgentWithAnthropic/README.md index 345c25142f..c2de7425d2 100644 --- a/dotnet/samples/02-agents/AgentWithAnthropic/README.md +++ b/dotnet/samples/02-agents/AgentWithAnthropic/README.md @@ -18,9 +18,9 @@ Before you begin, ensure you have the following prerequisites: **Note**: These samples use Anthropic Claude models. For more information, see [Anthropic documentation](https://docs.anthropic.com/). -## Using Anthropic with Azure Foundry +## Using Anthropic with Microsoft Foundry -To use Anthropic with Azure Foundry, you can check the sample [AgentProviders/Agent_With_Anthropic](../AgentProviders/Agent_With_Anthropic/README.md) for more details. +To use Anthropic with Microsoft Foundry, you can check the sample [AgentProviders/Agent_With_Anthropic](../AgentProviders/Agent_With_Anthropic/README.md) for more details. ## Samples diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj index 0b6c06a5a8..4c83380f90 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,8 +14,7 @@ - - + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index d6410d3308..1132e9ef1d 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -1,18 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. // This sample shows how to use the FoundryMemoryProvider to persist and recall memories for an agent. -// The sample stores conversation messages in an Azure AI Foundry memory store and retrieves relevant +// The sample stores conversation messages in a Microsoft Foundry memory store and retrieves relevant // memories for subsequent invocations, even across new sessions. // -// Note: Memory extraction in Azure AI Foundry is asynchronous and takes time. This sample demonstrates +// Note: Memory extraction in Microsoft Foundry is asynchronous and takes time. This sample demonstrates // a simple polling approach to wait for memory updates to complete before querying. using System.Text.Json; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; -using Microsoft.Agents.AI.FoundryMemory; +using Microsoft.Agents.AI.Foundry; string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "memory-store-sample"; @@ -37,7 +36,7 @@ FoundryMemoryProvider memoryProvider = new( memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123"))); -FoundryAgent agent = projectClient.AsAIAgent( +ChatClientAgent agent = projectClient.AsAIAgent( new ChatClientAgentOptions() { Name = "TravelAssistantWithFoundryMemory", @@ -62,7 +61,7 @@ await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session)); Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session)); -// Memory extraction in Azure AI Foundry is asynchronous and takes time to process. +// Memory extraction in Microsoft Foundry is asynchronous and takes time to process. // WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete. Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); await memoryProvider.WhenUpdatesCompletedAsync(); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md index bcc70b0103..b2d52e9837 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -1,6 +1,6 @@ -# Agent with Memory Using Azure AI Foundry +# Agent with Memory Using Microsoft Foundry -This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories across sessions. +This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories across sessions. ## Features Demonstrated @@ -13,7 +13,7 @@ This sample demonstrates how to create and run an agent that uses Azure AI Found ## Prerequisites -1. Azure subscription with Azure AI Foundry project +1. Azure subscription with Microsoft Foundry project 2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002) 3. .NET 10.0 SDK 4. Azure CLI logged in (`az login`) @@ -21,7 +21,7 @@ This sample demonstrates how to create and run an agent that uses Azure AI Found ## Environment Variables ```bash -# Azure AI Foundry project endpoint and memory store name +# Microsoft Foundry project endpoint and memory store name export AZURE_AI_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" export AZURE_AI_MEMORY_STORE_ID="my_memory_store" @@ -48,10 +48,10 @@ The agent will: ## Key Differences from Mem0 -| Aspect | Mem0 | Azure AI Foundry Memory | +| Aspect | Mem0 | Microsoft Foundry Memory | |--------|------|------------------------| | Authentication | API Key | Azure Identity (DefaultAzureCredential) | | Scope | ApplicationId, UserId, AgentId, ThreadId | Single `Scope` string | | Memory Types | Single memory store | User Profile + Chat Summary | -| Hosting | Mem0 cloud or self-hosted | Azure AI Foundry managed service | +| Hosting | Mem0 cloud or self-hosted | Microsoft Foundry managed service | | Store Creation | N/A (automatic) | Explicit via `EnsureMemoryStoreCreatedAsync` | diff --git a/dotnet/samples/02-agents/AgentWithMemory/README.md b/dotnet/samples/02-agents/AgentWithMemory/README.md index aa95012f68..c7f4510504 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/README.md @@ -7,7 +7,7 @@ These samples show how to create an agent with the Agent Framework that uses Mem |[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.| |[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.| |[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.| -|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.| +|[Memory with Microsoft Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories.| |[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.| -> **See also**: [Memory Search with Foundry Agents](../AgentsWithFoundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry agents. +> **See also**: [Memory Search with Foundry Agents](../AgentsWithFoundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents. diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md index 131adde82b..ec69136e14 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md @@ -13,7 +13,7 @@ This sample uses Qdrant for the vector store, but this can easily be swapped out - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. - An existing Qdrant instance. You can use a managed service or run a local instance using Docker, but the sample assumes the instance is running locally. -**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj index d90e1c394b..6a2bd7618c 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs index e049618a72..c68310eae5 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs @@ -7,7 +7,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using OpenAI; using OpenAI.Files; using OpenAI.Responses; @@ -44,10 +44,10 @@ ClientResult vectorStoreCreate = await vectorStoreClient.CreateVect FileSearchTool fileSearchTool = new([vectorStoreCreate.Value.Id]); #pragma warning restore OPENAI001 -AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( +ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( "AskContoso", - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(model: deploymentName) { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", Tools = { fileSearchTool } @@ -68,4 +68,4 @@ Console.WriteLine(await agent.RunAsync("What is the best way to maintain the Tra // Cleanup await fileClient.DeleteFileAsync(uploadResult.Value.Id); await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreCreate.Value.Id); -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); +await aiProjectClient.AgentAdministrationClient.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj new file mode 100644 index 0000000000..a25c626323 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj @@ -0,0 +1,54 @@ + + + + Exe + net10.0 + + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs new file mode 100644 index 0000000000..5bf0918ab3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Neo4j.AgentFramework.GraphRAG; +using Neo4j.Driver; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var neo4jUri = Environment.GetEnvironmentVariable("NEO4J_URI") ?? throw new InvalidOperationException("NEO4J_URI is not set."); +var neo4jUsername = Environment.GetEnvironmentVariable("NEO4J_USERNAME") ?? "neo4j"; +var neo4jPassword = Environment.GetEnvironmentVariable("NEO4J_PASSWORD") ?? throw new InvalidOperationException("NEO4J_PASSWORD is not set."); +var fulltextIndex = Environment.GetEnvironmentVariable("NEO4J_FULLTEXT_INDEX_NAME") ?? "search_chunks"; + +const string RetrievalQuery = """ + MATCH (node)-[:FROM_DOCUMENT]->(doc:Document)<-[:FILED]-(company:Company) + OPTIONAL MATCH (company)-[:FACES_RISK]->(risk:RiskFactor) + WITH node, score, company, doc, collect(DISTINCT risk.name)[0..5] AS risks + OPTIONAL MATCH (company)-[:MENTIONS]->(product:Product) + WITH node, score, company, doc, risks, collect(DISTINCT product.name)[0..5] AS products + RETURN + node.text AS text, + score, + company.name AS company, + company.ticker AS ticker, + doc.title AS title, + risks, + products + ORDER BY score DESC + """; + +await using var driver = GraphDatabase.Driver(new Uri(neo4jUri), AuthTokens.Basic(neo4jUsername, neo4jPassword)); +await driver.VerifyConnectivityAsync(); + +await using var provider = new Neo4jContextProvider( + driver, + new Neo4jContextProviderOptions + { + IndexName = fulltextIndex, + IndexType = IndexType.Fulltext, + RetrievalQuery = RetrievalQuery, + TopK = 5, + ContextPrompt = "Use the retrieved Neo4j graph context to answer accurately and call out when context is missing." + }); + +// 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 AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsIChatClient() + .AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new() + { + Instructions = "You are a helpful assistant that answers questions using Neo4j graph context." + }, + AIContextProviders = [provider] + }); + +AgentSession session = await agent.CreateSessionAsync(); + +foreach (var question in new[] +{ + "What products does Microsoft offer?", + "What risks does Apple face?", + "Tell me about NVIDIA's AI business and risk factors." +}) +{ + Console.WriteLine($">> {question}\n"); + Console.WriteLine(await agent.RunAsync(question, session)); + Console.WriteLine(); +} diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md new file mode 100644 index 0000000000..418407e831 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md @@ -0,0 +1,32 @@ +# Agent Framework Retrieval Augmented Generation (RAG) with Neo4j GraphRAG + +This sample demonstrates how to create and run an agent that uses the [Neo4j GraphRAG context provider](https://github.com/neo4j-labs/neo4j-maf-provider) with Microsoft Agent Framework for .NET. + +The sample uses a Neo4j fulltext index for retrieval and a Cypher `RetrievalQuery` to enrich results with related companies, products, and risk factors. + +## Prerequisites + +- .NET 10 SDK or later +- Azure OpenAI endpoint and chat deployment +- Azure CLI installed and authenticated +- A Neo4j database with chunked documents and a fulltext index such as `search_chunks` + +## Environment variables + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +$env:NEO4J_URI="neo4j+s://your-instance.databases.neo4j.io" +$env:NEO4J_USERNAME="neo4j" +$env:NEO4J_PASSWORD="your-password" +$env:NEO4J_FULLTEXT_INDEX_NAME="search_chunks" +``` + +## Build and run + +```powershell +dotnet build +dotnet run --framework net10.0 --no-build +``` + +The sample issues a few questions against the graph-backed retrieval provider and prints the responses to the console. diff --git a/dotnet/samples/02-agents/AgentWithRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/README.md index d606ac767c..9633220bf1 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/README.md +++ b/dotnet/samples/02-agents/AgentWithRAG/README.md @@ -8,3 +8,4 @@ These samples show how to create an agent with the Agent Framework that uses Ret |[RAG with Vector Store and custom schema](./AgentWithRAG_Step02_CustomVectorStoreRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a vector store. It also uses a custom schema for the documents stored in the vector store.| |[RAG with custom RAG data source](./AgentWithRAG_Step03_CustomRAGDataSource/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a custom RAG data source.| |[RAG with Foundry VectorStore service](./AgentWithRAG_Step04_FoundryServiceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with the Foundry VectorStore service.| +|[RAG with Neo4j GraphRAG](./AgentWithRAG_Step05_Neo4jGraphRAG/)|This sample demonstrates how to create and run an agent that uses a Neo4j-backed GraphRAG context provider with graph-enriched retrieval.| diff --git a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md index 5652fe9b0a..9b9411f17e 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md @@ -18,7 +18,7 @@ Before you begin, ensure you have the following prerequisites: - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource -**Note**: This sample uses Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: This sample uses Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj index 5239225499..a7df53251d 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj @@ -17,7 +17,7 @@ - + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs index a63063b6a5..5ab4406abd 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs @@ -19,10 +19,10 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); // Create a server side agent and expose it as an AIAgent. -AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( +ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( "Joker", - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'.", }) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md index e35cf01e90..2feaee22e2 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md @@ -20,8 +20,8 @@ To use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) MCP Inspector is up and running at http://127.0.0.1:6274 ``` 1. Open a web browser and navigate to the URL displayed in the terminal. If not opened automatically, this will open the MCP Inspector interface. -1. In the MCP Inspector interface, add the following environment variables to allow your MCP server to access Azure AI Foundry Project to create and run the agent: - - AZURE_AI_PROJECT_ENDPOINT = https://your-resource.openai.azure.com/ # Replace with your Azure AI Foundry Project endpoint +1. In the MCP Inspector interface, add the following environment variables to allow your MCP server to access Microsoft Foundry Project to create and run the agent: + - AZURE_AI_PROJECT_ENDPOINT = https://your-resource.openai.azure.com/ # Replace with your Microsoft Foundry Project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME = gpt-4o-mini # Replace with your model deployment name 1. Find and click the `Connect` button in the MCP Inspector interface to connect to the MCP server. 1. As soon as the connection is established, open the `Tools` tab in the MCP Inspector interface and select the `Joker` tool from the list. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs index bab09bc886..f7f3602fd3 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs @@ -13,7 +13,7 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -// Get Azure AI Foundry configuration from environment variables +// Get Microsoft Foundry configuration from environment variables var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs index fe93ed785c..915bc5c376 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use a chat history reducer to keep the context within model size limits. // Any implementation of Microsoft.Extensions.AI.IChatReducer can be used to customize how the chat history is reduced. // NOTE: this feature is only supported where the chat history is stored locally, such as with OpenAI Chat Completion. -// Where the chat history is stored server side, such as with Azure Foundry Agents, the service must manage the chat history size. +// Where the chat history is stored server side, such as with Microsoft Foundry Agents, the service must manage the chat history size. using Azure.AI.OpenAI; using Azure.Identity; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs index 11d3f561f4..d329816f13 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs @@ -2,7 +2,7 @@ #pragma warning disable CS0618 // Type or member is obsolete - sample uses deprecated PersistentAgentsClientExtensions -// This sample shows how to create an Azure AI Foundry Agent with the Deep Research Tool. +// This sample shows how to create a Microsoft Foundry Agent with the Deep Research Tool. using Azure.AI.Agents.Persistent; using Azure.Identity; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md index 1848b10826..b04e65cafc 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md @@ -11,10 +11,10 @@ Key features: Before running this sample, ensure you have: -1. An Azure AI Foundry project set up +1. A Microsoft Foundry project set up 2. A deep research model deployment (e.g., o3-deep-research) 3. A model deployment (e.g., gpt-4o) -4. A Bing Connection configured in your Azure AI Foundry project +4. A Bing Connection configured in your Microsoft Foundry project 5. Azure CLI installed and authenticated **Important**: Please visit the following documentation for detailed setup instructions: @@ -29,14 +29,14 @@ Pay special attention to the purple `Note` boxes in the Azure documentation. /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ ``` -You can find this in the Azure AI Foundry portal under **Management > Connected resources**, or retrieve it programmatically via the connections API (`.id` property). +You can find this in the Microsoft Foundry portal under **Management > Connected resources**, or retrieve it programmatically via the connections API (`.id` property). ## Environment Variables Set the following environment variables: ```powershell -# Replace with your Azure AI Foundry project endpoint +# Replace with your Microsoft Foundry project endpoint $env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/" # Replace with your Bing Grounding connection ID (full ARM resource URI) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs index 0c74ad86b1..4e526f8f41 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // This sample demonstrates how the ChatClientAgent persists chat history after each individual -// call to the AI service, using the SimulateServiceStoredChatHistory option. +// call to the AI service, using the RequirePerServiceCallChatHistoryPersistence option. // When an agent uses tools, FunctionInvokingChatClient may loop multiple times // (service call → tool execution → service call), and intermediate messages (tool calls and // results) are persisted after each service call. This allows you to inspect or recover them @@ -9,7 +9,7 @@ // yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases. // // To use end-of-run persistence instead (atomic run semantics), remove the -// SimulateServiceStoredChatHistory = true setting (or set it to false). End-of-run +// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run // persistence is the default behavior. // // The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one @@ -54,7 +54,7 @@ static string GetTime([Description("The city name.")] string city) => _ => $"{city}: time data not available." }; -// Create the agent — per-service-call persistence is enabled via SimulateServiceStoredChatHistory. +// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence. // The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat // history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory(). IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ? @@ -64,7 +64,7 @@ AIAgent agent = chatClient.AsAIAgent( new ChatClientAgentOptions { Name = "WeatherAssistant", - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, ChatOptions = new() { Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.", diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md index 461f76aaf4..9b96562a66 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md @@ -1,19 +1,19 @@ # In-Function-Loop Checkpointing -This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `SimulateServiceStoredChatHistory` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop. +This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `RequirePerServiceCallChatHistoryPersistence` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop. ## What This Sample Shows -When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `SimulateServiceStoredChatHistory = true`, chat history is persisted after each service call via the `ServiceStoredSimulatingChatClient` decorator: +When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `RequirePerServiceCallChatHistoryPersistence = true`, chat history is persisted after each service call via the `PerServiceCallChatHistoryPersistingChatClient` decorator: -- A `ServiceStoredSimulatingChatClient` decorator is inserted into the chat client pipeline +- A `PerServiceCallChatHistoryPersistingChatClient` decorator is inserted into the chat client pipeline - Before each service call, the decorator loads history from the `ChatHistoryProvider` and prepends it to the request - After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages - Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically -By default (without `SimulateServiceStoredChatHistory`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `SimulateServiceStoredChatHistory = true` on `ChatClientAgentOptions`. +By default (without `RequirePerServiceCallChatHistoryPersistence`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `RequirePerServiceCallChatHistoryPersistence = true` on `ChatClientAgentOptions`. -With `SimulateServiceStoredChatHistory` = true, the behavior matches that of chat history stored in the underlying AI service exactly. +With `RequirePerServiceCallChatHistoryPersistence` = true, the behavior matches that of chat history stored in the underlying AI service exactly. Per-service-call persistence is useful for: - **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted @@ -29,7 +29,7 @@ The sample asks the agent about the weather and time in three cities. The model ``` ChatClientAgent └─ FunctionInvokingChatClient (handles tool call loop) - └─ ServiceStoredSimulatingChatClient (persists after each service call) + └─ PerServiceCallChatHistoryPersistingChatClient (persists after each service call) └─ Leaf IChatClient (Azure OpenAI) ``` diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index c5258ba9f4..6cdf80625b 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -18,7 +18,7 @@ Before you begin, ensure you have the following prerequisites: - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. -**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj index d861331d9f..4c83380f90 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs index c6b2d5c764..9a22d1cddf 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs @@ -1,13 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. // This sample shows how to create, use, and clean up a FoundryAgent backed by a server-side -// versioned agent in Azure AI Foundry. It demonstrates the full lifecycle: +// versioned agent in Microsoft Foundry. It demonstrates the full lifecycle: // create agent version -> wrap as FoundryAgent -> run -> delete. using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -18,10 +18,10 @@ const string JokerName = "JokerAgent"; AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); // Create a server-side agent version using the native SDK. -AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( +ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( JokerName, - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes.", })); @@ -33,4 +33,4 @@ FoundryAgent agent = aiProjectClient.AsAIAgent(agentVersion); Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); // Cleanup: deletes the agent and all its versions. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); +await aiProjectClient.AgentAdministrationClient.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj index 5e73fd236a..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs index 7a66555c18..6057d71895 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs @@ -4,10 +4,10 @@ // Server-side conversations persist on the Foundry service and are visible in the Foundry Project UI. // Use this when you need conversation history to be stored and accessible server-side. +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -15,12 +15,20 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLO // 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. -FoundryAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +ChatClientAgent agent = aiProjectClient .AsAIAgent(deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); +ProjectConversationsClient conversationsClient = aiProjectClient + .GetProjectOpenAIClient() + .GetProjectConversationsClient(); + +ProjectConversation conversation = (await conversationsClient.CreateProjectConversationAsync().ConfigureAwait(false)).Value; + // CreateConversationSessionAsync creates a server-side ProjectConversation // that persists on the Foundry service and is visible in the Foundry Project UI. -AgentSession session = await agent.CreateConversationSessionAsync(); +AgentSession session = await agent.CreateSessionAsync(conversation.Id); Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session)); Console.WriteLine(await agent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj index 1189939bc0..60190545a1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj index 72af634725..fd54882035 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj index 96cdf948fe..1f596a94a1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj index 6064cf9334..c2bb03bffd 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj index b30baccd54..d1cfe0da4a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj index 1f5e37c1a3..5dfa730c8a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj index f739f56123..7bd94bd716 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs index 22f03e27b3..d79c1122b7 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs @@ -5,7 +5,7 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj index 4602e9c9e0..8671b3027c 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs index 5cc2720dd9..1968b89ca1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs index 4ab548403d..e244bb3cb0 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string connectionId = Environment.GetEnvironmentVariable("AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID") ?? throw new InvalidOperationException("AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID is not set."); string instanceName = Environment.GetEnvironmentVariable("AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME") ?? throw new InvalidOperationException("AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs index aafca6b8bd..a5f8bf845a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string sharepointConnectionId = Environment.GetEnvironmentVariable("SHAREPOINT_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("SHAREPOINT_PROJECT_CONNECTION_ID is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs index 7c49afa7ee..69954b9483 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj index 4602e9c9e0..8671b3027c 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs index 9e1a902f29..b00cc2f801 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs @@ -7,9 +7,10 @@ using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.AI.Projects.Agents; +using Azure.AI.Projects.Memory; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj index e51f57c439..53b8a3af34 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md index b5802053b3..8cc964e227 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -1,6 +1,6 @@ # Getting started with Foundry Agents -These samples demonstrate how to use Azure AI Foundry with Agent Framework. +These samples demonstrate how to use Microsoft Foundry with Agent Framework. ## Quick start diff --git a/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Properties/launchSettings.json b/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Properties/launchSettings.json index 5ec486626c..dcb4830863 100644 --- a/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Properties/launchSettings.json +++ b/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Properties/launchSettings.json @@ -2,11 +2,11 @@ "profiles": { "GetWeather": { "commandName": "Project", - "commandLineArgs": "..\\..\\..\\..\\..\\..\\..\\..\\agent-samples\\chatclient\\GetWeather.yaml \"What is the weather in Cambridge, MA in °C?\"" + "commandLineArgs": "..\\..\\..\\..\\..\\..\\..\\..\\declarative-agents\\agent-samples\\chatclient\\GetWeather.yaml \"What is the weather in Cambridge, MA in °C?\"" }, "Assistant": { "commandName": "Project", - "commandLineArgs": "..\\..\\..\\..\\..\\..\\..\\..\\agent-samples\\chatclient\\Assistant.yaml \"Tell me a joke about a pirate in Italian.\"" + "commandLineArgs": "..\\..\\..\\..\\..\\..\\..\\..\\declarative-agents\\agent-samples\\chatclient\\Assistant.yaml \"Tell me a joke about a pirate in Italian.\"" } } } \ No newline at end of file diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj index d861331d9f..4c83380f90 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index bedf87b8c5..7d64ad9399 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend, that uses a Hosted MCP Tool. -// In this case the Azure Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. +// This sample shows how to create and use a simple AI agent with Microsoft Foundry Agents as the backend, that uses a Hosted MCP Tool. +// In this case the Microsoft Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. // The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool. using Azure.AI.Projects; @@ -31,10 +31,10 @@ var mcpTool = ResponseTool.CreateMcpTool( toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)); // Create a server side agent with the mcp tool, and expose it as an AIAgent. -AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( +ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( "MicrosoftLearnAgent", - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: model) + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(model: model) { Instructions = "You answer questions by searching the Microsoft Learn content only.", Tools = { mcpTool } @@ -47,7 +47,7 @@ AgentSession session = await agent.CreateSessionAsync(); Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", session)); // Cleanup for sample purposes. -aiProjectClient.Agents.DeleteAgent(agent.Name); +aiProjectClient.AgentAdministrationClient.DeleteAgent(agent.Name); // **** MCP Tool with Approval Required **** // ***************************************** @@ -61,10 +61,10 @@ var mcpToolWithApproval = ResponseTool.CreateMcpTool( toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval)); // Create an agent with the MCP tool that requires approval. -AgentVersion agentVersionWithApproval = await aiProjectClient.Agents.CreateAgentVersionAsync( +ProjectsAgentVersion agentVersionWithApproval = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( "MicrosoftLearnAgentWithApproval", - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: model) + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(model: model) { Instructions = "You answer questions by searching the Microsoft Learn content only.", Tools = { mcpToolWithApproval } diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md index a172ec63cf..d03833b826 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md @@ -3,14 +3,14 @@ Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini ``` diff --git a/dotnet/samples/02-agents/ModelContextProtocol/README.md b/dotnet/samples/02-agents/ModelContextProtocol/README.md index be1aa83513..9cbaecad4b 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/README.md @@ -11,7 +11,7 @@ Before you begin, ensure you have the following prerequisites: - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. -**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj b/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj index a7648b7a10..dd6854fbfe 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index 5b1f59ac62..458c280ca1 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -4,19 +4,19 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; namespace WorkflowFoundryAgentSample; /// -/// This sample shows how to use Azure Foundry Agents within a workflow. +/// This sample shows how to use Microsoft Foundry Agents within a workflow. /// /// /// Pre-requisites: /// - Foundational samples should be completed first. -/// - An Azure Foundry project endpoint and model id. +/// - A Microsoft Foundry project endpoint and model ID. /// public static class Program { @@ -58,9 +58,9 @@ public static class Program finally { // Cleanup the agents created for the sample. - await aiProjectClient.Agents.DeleteAgentAsync(frenchAgent.Name); - await aiProjectClient.Agents.DeleteAgentAsync(spanishAgent.Name); - await aiProjectClient.Agents.DeleteAgentAsync(englishAgent.Name); + await aiProjectClient.AgentAdministrationClient.DeleteAgentAsync(frenchAgent.Name); + await aiProjectClient.AgentAdministrationClient.DeleteAgentAsync(spanishAgent.Name); + await aiProjectClient.AgentAdministrationClient.DeleteAgentAsync(englishAgent.Name); } } @@ -76,10 +76,10 @@ public static class Program AIProjectClient aiProjectClient, string model) { - AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( $"{targetLanguage} Translator", - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: model) + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(model: model) { Instructions = $"You are a translation assistant that translates the provided text to {targetLanguage}.", })); diff --git a/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj b/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj index dac2f49921..e7a4c3a774 100644 --- a/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj +++ b/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj b/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj index 0bc83997d0..80158364ae 100644 --- a/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj +++ b/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj @@ -26,11 +26,11 @@ - + - + Always diff --git a/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs b/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs index 5b0458f23d..fe7db42611 100644 --- a/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs @@ -97,7 +97,7 @@ internal sealed class Program agentDescription: "Escalate agent for human support"); } - private static PromptAgentDefinition DefineSelfServiceAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineSelfServiceAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -144,7 +144,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition DefineTicketingAgent(IConfiguration configuration, TicketingPlugin plugin) => + private static DeclarativeAgentDefinition DefineTicketingAgent(IConfiguration configuration, TicketingPlugin plugin) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -208,7 +208,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition DefineTicketRoutingAgent(IConfiguration configuration, TicketingPlugin plugin) => + private static DeclarativeAgentDefinition DefineTicketRoutingAgent(IConfiguration configuration, TicketingPlugin plugin) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -253,7 +253,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition DefineWindowsSupportAgent(IConfiguration configuration, TicketingPlugin plugin) => + private static DeclarativeAgentDefinition DefineWindowsSupportAgent(IConfiguration configuration, TicketingPlugin plugin) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -323,7 +323,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition DefineResolutionAgent(IConfiguration configuration, TicketingPlugin plugin) => + private static DeclarativeAgentDefinition DefineResolutionAgent(IConfiguration configuration, TicketingPlugin plugin) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -357,7 +357,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition TicketEscalationAgent(IConfiguration configuration, TicketingPlugin plugin) => + private static DeclarativeAgentDefinition TicketEscalationAgent(IConfiguration configuration, TicketingPlugin plugin) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = diff --git a/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj b/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj index cd533a0707..504b948396 100644 --- a/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj +++ b/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj @@ -26,11 +26,11 @@ - + - + Always diff --git a/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs b/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs index e415c7aad0..bbf388737d 100644 --- a/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs @@ -88,7 +88,7 @@ internal sealed class Program agentDescription: "Weather agent for DeepResearch workflow"); } - private static PromptAgentDefinition DefineResearchAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineResearchAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -114,13 +114,13 @@ internal sealed class Program """, Tools = { - //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + //ProjectsAgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available // new BingGroundingSearchToolParameters( // [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))])) } }; - private static PromptAgentDefinition DefinePlannerAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefinePlannerAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = // TODO: Use Structured Inputs / Prompt Template @@ -139,7 +139,7 @@ internal sealed class Program """ }; - private static PromptAgentDefinition DefineManagerAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineManagerAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = // TODO: Use Structured Inputs / Prompt Template @@ -225,7 +225,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition DefineSummaryAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineSummaryAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -240,18 +240,18 @@ internal sealed class Program """ }; - private static PromptAgentDefinition DefineKnowledgeAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineKnowledgeAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Tools = { - //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + //ProjectsAgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available // new BingGroundingSearchToolParameters( // [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))])) } }; - private static PromptAgentDefinition DefineCoderAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineCoderAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -265,7 +265,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition DefineWeatherAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineWeatherAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -274,7 +274,7 @@ internal sealed class Program """, Tools = { - AgentTool.CreateOpenApiTool( + ProjectsAgentTool.CreateOpenApiTool( new OpenApiFunctionDefinition( "weather-forecast", BinaryData.FromString(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "wttr.json"))), diff --git a/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj b/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj index 6a9c4957c2..c92a9ffbf1 100644 --- a/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj +++ b/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj @@ -27,7 +27,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj index fce40b64d4..243d6d3d52 100644 --- a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj +++ b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs index 0d80cb686d..f7e4dea673 100644 --- a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs @@ -143,7 +143,7 @@ internal sealed class Program string? repoFolder = GetRepoFolder(); if (repoFolder is not null) { - workflowFile = Path.Combine(repoFolder, "workflow-samples", workflowFile); + workflowFile = Path.Combine(repoFolder, "declarative-agents", "workflow-samples", workflowFile); workflowFile = Path.ChangeExtension(workflowFile, ".yaml"); } } diff --git a/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj b/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj index f890fb30a8..5ed2187e2f 100644 --- a/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj +++ b/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs b/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs index a1bd9de8f9..8413dfb5ec 100644 --- a/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs @@ -67,9 +67,9 @@ internal sealed class Program agentDescription: "Provides information about the restaurant menu"); } - private static PromptAgentDefinition DefineMenuAgent(IConfiguration configuration, AIFunction[] functions) + private static DeclarativeAgentDefinition DefineMenuAgent(IConfiguration configuration, AIFunction[] functions) { - PromptAgentDefinition agentDefinition = + DeclarativeAgentDefinition agentDefinition = new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = diff --git a/dotnet/samples/03-workflows/Declarative/GenerateCode/Program.cs b/dotnet/samples/03-workflows/Declarative/GenerateCode/Program.cs index 54c77d4077..c9bbc3502e 100644 --- a/dotnet/samples/03-workflows/Declarative/GenerateCode/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/GenerateCode/Program.cs @@ -60,7 +60,7 @@ internal sealed class Program string? repoFolder = GetRepoFolder(); if (repoFolder is not null) { - workflowFile = Path.Combine(repoFolder, "workflow-samples", workflowFile); + workflowFile = Path.Combine(repoFolder, "declarative-agents", "workflow-samples", workflowFile); workflowFile = Path.ChangeExtension(workflowFile, ".yaml"); } } diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj index f9379f38a3..e2062e40e4 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj @@ -27,11 +27,11 @@ - + - + Always diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs index 7852a8730f..a871d233ca 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs @@ -8,7 +8,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Shared.Foundry; @@ -46,7 +46,7 @@ internal sealed class Program await CreateAgentsAsync(aiProjectClient, configuration); // Ensure workflow agent exists in Foundry. - AgentVersion agentVersion = await CreateWorkflowAsync(aiProjectClient, configuration); + ProjectsAgentVersion agentVersion = await CreateWorkflowAsync(aiProjectClient, configuration); string workflowInput = GetWorkflowInput(args); @@ -86,7 +86,7 @@ internal sealed class Program } } - private static async Task CreateWorkflowAsync(AIProjectClient agentClient, IConfiguration configuration) + private static async Task CreateWorkflowAsync(AIProjectClient agentClient, IConfiguration configuration) { string workflowYaml = File.ReadAllText("MathChat.yaml"); @@ -114,7 +114,7 @@ internal sealed class Program agentDescription: "Teacher agent for MathChat workflow"); } - private static PromptAgentDefinition DefineStudentAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineStudentAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -127,7 +127,7 @@ internal sealed class Program """ }; - private static PromptAgentDefinition DefineTeacherAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineTeacherAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = diff --git a/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj b/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj index 45bc44eaf3..ae6a0046ef 100644 --- a/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj +++ b/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs b/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs index 0a6f99f920..4fccbcbc35 100644 --- a/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs @@ -68,7 +68,7 @@ internal sealed class Program agentDescription: "Chats with the user with location awareness."); } - private static PromptAgentDefinition DefineLocationTriageAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineLocationTriageAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -79,7 +79,7 @@ internal sealed class Program """ }; - private static PromptAgentDefinition DefineLocationCaptureAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineLocationCaptureAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -128,7 +128,7 @@ internal sealed class Program } }; - private static PromptAgentDefinition DefineLocationAwareAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineLocationAwareAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { // Parameterized instructions reference the "location" input argument. diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj index 67229da4b8..f2d23835a1 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj +++ b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs index 8875d204f2..7d8323a45a 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs @@ -63,9 +63,9 @@ internal sealed class Program agentDescription: "Provides information about the restaurant menu"); } - private static PromptAgentDefinition DefineMenuAgent(IConfiguration configuration, AIFunction[] functions) + private static DeclarativeAgentDefinition DefineMenuAgent(IConfiguration configuration, AIFunction[] functions) { - PromptAgentDefinition agentDefinition = + DeclarativeAgentDefinition agentDefinition = new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = diff --git a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj index 317d93c4e9..ea122bd971 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj +++ b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs index 61ce1afc70..560b6d25ca 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs @@ -30,7 +30,7 @@ namespace Demo.Workflows.Declarative.InvokeMcpTool; /// Integrating with MCP-compatible services /// /// -/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Azure foundry MCP server to get AI model details. +/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Microsoft Foundry MCP server to get AI model details. /// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input, /// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results. /// @@ -125,9 +125,9 @@ internal sealed class Program agentDescription: "Provides information based on search results"); } - private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration) + private static DeclarativeAgentDefinition DefineSearchAgent(IConfiguration configuration) { - return new PromptAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel)) + return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = """ diff --git a/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj b/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj index 20e5843554..ac4b61d7f3 100644 --- a/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj +++ b/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj @@ -26,11 +26,11 @@ - + - + Always diff --git a/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs b/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs index 5d73edd26d..1f4585c2c7 100644 --- a/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs @@ -67,7 +67,7 @@ internal sealed class Program agentDescription: "Editor agent for Marketing workflow"); } - private static PromptAgentDefinition DefineAnalystAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineAnalystAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -79,13 +79,13 @@ internal sealed class Program """, Tools = { - //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + //ProjectsAgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available // new BingGroundingSearchToolParameters( // [new BingGroundingSearchConfiguration(configuration[Application.Settings.FoundryGroundingTool])])) } }; - private static PromptAgentDefinition DefineWriterAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineWriterAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -96,7 +96,7 @@ internal sealed class Program """ }; - private static PromptAgentDefinition DefineEditorAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineEditorAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = diff --git a/dotnet/samples/03-workflows/Declarative/README.md b/dotnet/samples/03-workflows/Declarative/README.md index 2ad3e59c0d..6bd2c85824 100644 --- a/dotnet/samples/03-workflows/Declarative/README.md +++ b/dotnet/samples/03-workflows/Declarative/README.md @@ -6,7 +6,7 @@ to build a `Workflow` that may be executed using the same pattern as any code-ba ## Configuration These samples must be configured to create and use agents your -[Azure Foundry Project](https://learn.microsoft.com/azure/ai-foundry). +[Microsoft Foundry Project](https://learn.microsoft.com/azure/ai-foundry). ### Settings @@ -18,9 +18,9 @@ The configuraton required by the samples is: |Setting Name| Description| |:--|:--| -|AZURE_AI_PROJECT_ENDPOINT| The endpoint URL of your Azure Foundry Project.| +|AZURE_AI_PROJECT_ENDPOINT| The endpoint URL of your Microsoft Foundry Project.| |AZURE_AI_MODEL_DEPLOYMENT_NAME| The name of the model deployment to use -|AZURE_AI_BING_CONNECTION_ID| The name of the Bing Grounding connection configured in your Azure Foundry Project.| +|AZURE_AI_BING_CONNECTION_ID| The name of the Bing Grounding connection configured in your Microsoft Foundry Project.| To set your secrets with .NET Secret Manager: @@ -42,13 +42,13 @@ To set your secrets with .NET Secret Manager: dotnet user-secrets init ``` -4. Define setting that identifies your Azure Foundry Project (endpoint): +4. Define setting that identifies your Microsoft Foundry Project (endpoint): ``` dotnet user-secrets set "AZURE_AI_PROJECT_ENDPOINT" "https://..." ``` -5. Define setting that identifies your Azure Foundry Model Deployment (endpoint): +5. Define setting that identifies your Microsoft Foundry Model Deployment (endpoint): ``` dotnet user-secrets set "AZURE_AI_MODEL_DEPLOYMENT_NAME" "gpt-5" @@ -70,7 +70,7 @@ $env:AZURE_AI_BING_CONNECTION_ID="mybinggrounding" ### Authorization -Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Azure Foundry Project: +Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Microsoft Foundry Project: ``` az login diff --git a/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs b/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs index 4f1d31a2ea..8cbee41e63 100644 --- a/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs @@ -62,7 +62,7 @@ internal sealed class Program agentDescription: "Teacher agent for MathChat workflow"); } - private static PromptAgentDefinition DefineStudentAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineStudentAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = @@ -75,7 +75,7 @@ internal sealed class Program """ }; - private static PromptAgentDefinition DefineTeacherAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineTeacherAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = diff --git a/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj b/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj index 8136706b8d..d8375f70cd 100644 --- a/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj +++ b/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj @@ -26,11 +26,11 @@ - + - + Always diff --git a/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs b/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs index 9e9bd65b6b..61b751d39c 100644 --- a/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs @@ -58,7 +58,7 @@ internal sealed class Program agentDescription: "Searches documents on Microsoft Learn"); } - private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration) => + private static DeclarativeAgentDefinition DefineSearchAgent(IConfiguration configuration) => new(configuration.GetValue(Application.Settings.FoundryModel)) { Instructions = diff --git a/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj b/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj index a44e140f1f..e5cf939e15 100644 --- a/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj +++ b/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/README.md b/dotnet/samples/03-workflows/README.md index 1ab52106ec..d17148d60d 100644 --- a/dotnet/samples/03-workflows/README.md +++ b/dotnet/samples/03-workflows/README.md @@ -26,7 +26,7 @@ Once completed, please proceed to the other samples listed below. | Sample | Concepts | |--------|----------| -| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Azure Foundry agents in a workflow through `ChatClientAgent` | +| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Microsoft Foundry agents in a workflow through `ChatClientAgent` | | [Custom Agent Executors](./Agents/CustomAgentExecutors) | Shows how to create a custom agent executor for more complex scenarios | | [Workflow as an Agent](./Agents/WorkflowAsAnAgent) | Illustrates how to encapsulate a workflow as an agent | | [Group Chat with Tool Approval](./Agents/GroupChatToolApproval) | Shows multi-agent group chat with tool approval requests and human-in-the-loop interaction | diff --git a/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/03_AgentWorkflowPatterns.csproj b/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/03_AgentWorkflowPatterns.csproj index e926a8375a..f0b7858971 100644 --- a/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/03_AgentWorkflowPatterns.csproj +++ b/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/03_AgentWorkflowPatterns.csproj @@ -6,6 +6,7 @@ enable enable + $(NoWarn);MAAIW001 diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj index 5a7ef20208..98b7b293c8 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj @@ -1,4 +1,4 @@ - + Exe @@ -23,7 +23,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 13c01be156..d5f1c9a88d 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -20,7 +20,7 @@ internal static class HostAgentFactory // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); - AgentRecord agentRecord = await aiProjectClient.Agents.GetAgentAsync(agentName); + ProjectsAgentRecord agentRecord = await aiProjectClient.AgentAdministrationClient.GetAgentAsync(agentName); AIAgent agent = aiProjectClient.AsAIAgent(agentRecord, tools: tools); AgentCard agentCard = agentType.ToUpperInvariant() switch diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/README.md b/dotnet/samples/05-end-to-end/A2AClientServer/README.md index cff5b40e2d..1f49d56f80 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/README.md +++ b/dotnet/samples/05-end-to-end/A2AClientServer/README.md @@ -51,7 +51,7 @@ dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentType "lo ### Configuring for use with Azure AI Agents -You must create the agents in an Azure AI Foundry project and then provide the project endpoint and agents ids. The instructions for each agent are as follows: +You must create the agents in a Microsoft Foundry project and then provide the project endpoint and agent IDs. The instructions for each agent are as follows: - Invoice Agent ``` diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs index 78a0aa62e9..329ad74ea2 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. -// Uses Microsoft Agent Framework with Azure AI Foundry. +// Uses Microsoft Agent Framework with Microsoft Foundry. // Ready for deployment to Foundry Hosted Agent service. using System.ClientModel.Primitives; diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md index c080331a87..58ea174718 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md @@ -4,7 +4,7 @@ This sample demonstrates how to build a hosted agent that uses local C# function Key features: - Defining local C# functions as agent tools using `AIFunctionFactory` -- Using `AIProjectClient` to discover the OpenAI connection from the Azure AI Foundry project +- Using `AIProjectClient` to discover the OpenAI connection from the Microsoft Foundry project - Building a `ChatClientAgent` with custom instructions and tools - Deploying to the Foundry Hosted Agent service @@ -15,7 +15,7 @@ Key features: Before running this sample, ensure you have: 1. .NET 10 SDK installed -2. An Azure AI Foundry Project with a chat model deployed (e.g., gpt-4o-mini) +2. A Microsoft Foundry Project with a chat model deployed (e.g., gpt-4o-mini) 3. Azure CLI installed and authenticated (`az login`) ## Environment Variables @@ -23,7 +23,7 @@ Before running this sample, ensure you have: Set the following environment variables: ```powershell -# Replace with your Azure AI Foundry project endpoint +# Replace with your Microsoft Foundry project endpoint $env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name" # Optional, defaults to gpt-4o-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs index 8d21eef20a..ca36341b97 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // This sample demonstrates a multi-agent workflow with Writer and Reviewer agents -// using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder. +// using Microsoft Foundry AIProjectClient and the Agent Framework WorkflowBuilder. #pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md index 314320880b..4f589be86e 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md @@ -42,7 +42,7 @@ which provisions a REST API endpoint compatible with the OpenAI Responses protoc Before running this sample, ensure you have: -1. **Azure AI Foundry Project** +1. **Microsoft Foundry Project** - Project created. - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) - Note your project endpoint URL and model deployment name diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml index 70b82abf7c..672444cdd1 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml @@ -4,7 +4,7 @@ name: FoundryMultiAgent displayName: "Foundry Multi-Agent Workflow" description: > A multi-agent workflow featuring a Writer and Reviewer that collaborate - to create and refine content using Azure AI Foundry PersistentAgentsClient. + to create and refine content using Microsoft Foundry PersistentAgentsClient. metadata: authors: - Microsoft Agent Framework Team diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs index 80edf42089..185afd186f 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. -// Uses Microsoft Agent Framework with Azure AI Foundry. +// Uses Microsoft Agent Framework with Microsoft Foundry. // Ready for deployment to Foundry Hosted Agent service. #pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features @@ -70,17 +70,19 @@ string GetAvailableHotels( // Build response var result = new StringBuilder(); - result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):"); - result.AppendLine(); + result + .AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):") + .AppendLine(); foreach (var hotel in availableHotels) { var totalCost = hotel.PricePerNight * nights; - result.AppendLine($"**{hotel.Name}**"); - result.AppendLine($" Location: {hotel.Location}"); - result.AppendLine($" Rating: {hotel.Rating}/5"); - result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})"); - result.AppendLine(); + result + .AppendLine($"**{hotel.Name}**") + .AppendLine($" Location: {hotel.Location}") + .AppendLine($" Rating: {hotel.Rating}/5") + .AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})") + .AppendLine(); } return result.ToString(); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md index 31f3fc1a9d..e9dbced0a7 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md @@ -39,7 +39,7 @@ which provisions a REST API endpoint compatible with the OpenAI Responses protoc Before running this sample, ensure you have: -1. **Azure AI Foundry Project** +1. **Microsoft Foundry Project** - Project created. - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) - Note your project endpoint URL and model deployment name @@ -57,7 +57,7 @@ Before running this sample, ensure you have: Set the following environment variables (matching `agent.yaml`): -- `AZURE_AI_PROJECT_ENDPOINT` - Your Azure AI Foundry project endpoint URL (required) +- `AZURE_AI_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) - `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4o-mini`) **PowerShell:** diff --git a/dotnet/samples/05-end-to-end/HostedAgents/README.md b/dotnet/samples/05-end-to-end/HostedAgents/README.md index 919aa4b580..2042212a2e 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/README.md @@ -20,7 +20,7 @@ Before running any sample, ensure you have: 1. **.NET 10 SDK** or later — [Download](https://dotnet.microsoft.com/download/dotnet/10.0) 2. **Azure CLI** installed — [Install guide](https://learn.microsoft.com/cli/azure/install-azure-cli) -3. **Azure OpenAI** or **Azure AI Foundry project** with a chat model deployed (e.g., `gpt-4o-mini`) +3. **Azure OpenAI** or **Microsoft Foundry project** with a chat model deployed (e.g., `gpt-4o-mini`) ### Authenticate with Azure CLI @@ -39,14 +39,14 @@ Most samples require one or more of these environment variables: |----------|---------|-------------| | `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL | | `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-4o-mini`) | -| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Azure AI Foundry project endpoint | +| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Microsoft Foundry project endpoint | | `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-4o-mini`) | See each sample's README for the specific variables required. -## Azure AI Foundry Setup (for samples that use Foundry) +## Microsoft Foundry Setup (for samples that use Foundry) -Some samples (`AgentWithLocalTools`, `FoundrySingleAgent`, `FoundryMultiAgent`) connect to an Azure AI Foundry project. If you're using these samples, you'll need additional setup. +Some samples (`AgentWithLocalTools`, `FoundrySingleAgent`, `FoundryMultiAgent`) connect to a Microsoft Foundry project. If you're using these samples, you'll need additional setup. ### Azure AI Developer Role @@ -61,7 +61,7 @@ az role assignment create ` > **Note**: You need **Owner** or **User Access Administrator** permissions on the resource to assign roles. If you don't have this, you may need to request JIT (Just-In-Time) elevated access via [Azure PIM](https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadmigratedresource). -For more details on permissions, see [Azure AI Foundry Permissions](https://aka.ms/FoundryPermissions). +For more details on permissions, see [Microsoft Foundry Permissions](https://aka.ms/FoundryPermissions). ## Running a Sample diff --git a/dotnet/samples/AGENTS.md b/dotnet/samples/AGENTS.md index f515f531eb..7208facf36 100644 --- a/dotnet/samples/AGENTS.md +++ b/dotnet/samples/AGENTS.md @@ -28,7 +28,7 @@ dotnet/samples/ │ ├── AGUI/ # AG-UI protocol samples │ ├── DeclarativeAgents/ # Declarative agent definitions │ ├── DevUI/ # DevUI samples -│ ├── AgentsWithFoundry/ # Azure AI Foundry samples (FoundryAgent + AsAIAgent extensions) +│ ├── AgentsWithFoundry/ # Microsoft Foundry samples (FoundryAgent + AsAIAgent extensions) │ └── ModelContextProtocol/ # MCP server/client patterns ├── 03-workflows/ # Workflow patterns │ ├── _StartHere/ # Introductory workflow samples diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md index e5d3b90ae2..577b8bccbd 100644 --- a/dotnet/samples/README.md +++ b/dotnet/samples/README.md @@ -3,7 +3,7 @@ The agent framework samples are designed to help you get started with building AI-powered agents from various providers. -The Agent Framework supports building agents using various infererence and inference-style services. +The Agent Framework supports building agents using various inference and inference-style services. All these are supported using the single `ChatClientAgent` class. The Agent Framework also supports creating proxy agents, that allow accessing remote agents as if they diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj index 9acfb1fab3..9f5668c812 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj @@ -3,7 +3,7 @@ Microsoft.Agents.AI $(NoWarn);MEAI001 - true + true diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs deleted file mode 100644 index 479ab894ae..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs +++ /dev/null @@ -1,935 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects.Agents; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; -using OpenAI; -using OpenAI.Responses; - -namespace Azure.AI.Projects; - -/// -/// Provides extension methods for . -/// -[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] -public static partial class AzureAIProjectChatClientExtensions -{ - /// - /// Uses an existing server side agent, wrapped as a using the provided and . - /// - /// The to create the with. Cannot be . - /// The representing the name and version of the server side agent to create a for. Cannot be . - /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. - /// Thrown when or is . - /// The agent with the specified name was not found. - /// - /// When instantiating a by using an , minimal information will be available about the agent in the instance level, and any logic that relies - /// on to retrieve information about the agent like will receive as the result. - /// - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - AgentReference agentReference, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentReference); - ThrowIfInvalidAgentName(agentReference.Name); - - var innerAgent = AsChatClientAgent( - aiProjectClient, - agentReference, - new ChatClientAgentOptions() - { - Id = $"{agentReference.Name}:{agentReference.Version}", - Name = agentReference.Name, - ChatOptions = new() { Tools = tools }, - }, - clientFactory, - services); - - return new FoundryAgent(aiProjectClient, innerAgent); - } - - /// - /// Asynchronously retrieves an existing server side agent, wrapped as a using the provided . - /// - /// The to create the with. Cannot be . - /// The name of the server side agent to create a for. Cannot be or whitespace. - /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// The to monitor for cancellation requests. The default is . - /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. - /// Thrown when or is . - /// Thrown when is empty or whitespace, or when the agent with the specified name was not found. - /// The agent with the specified name was not found. - [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] - public static async Task GetAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - ThrowIfInvalidAgentName(name); - - AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, name, cancellationToken).ConfigureAwait(false); - - return AsAIAgent( - aiProjectClient, - agentRecord, - tools, - clientFactory, - services); - } - - /// - /// Uses an existing server side agent, wrapped as a using the provided and . - /// - /// The client used to interact with Azure AI Agents. Cannot be . - /// The agent record to be converted. The latest version will be used. Cannot be . - /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations based on the latest version of the Azure AI Agent. - /// Thrown when or is . - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - AgentRecord agentRecord, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentRecord); - - var allowDeclarativeMode = tools is not { Count: > 0 }; - - var innerAgent = AsChatClientAgent( - aiProjectClient, - agentRecord, - tools, - clientFactory, - !allowDeclarativeMode, - services); - - return new FoundryAgent(aiProjectClient, innerAgent); - } - - /// - /// Uses an existing server side agent, wrapped as a using the provided and . - /// - /// The client used to interact with Azure AI Agents. Cannot be . - /// The agent version to be converted. Cannot be . - /// In-process invocable tools to be provided. If no tools are provided manual handling will be necessary to invoke in-process tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations based on the provided version of the Azure AI Agent. - /// Thrown when or is . - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - AgentVersion agentVersion, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentVersion); - - var allowDeclarativeMode = tools is not { Count: > 0 }; - - var innerAgent = AsChatClientAgent( - aiProjectClient, - agentVersion, - tools, - clientFactory, - !allowDeclarativeMode, - services); - - return new FoundryAgent(aiProjectClient, innerAgent); - } - - /// - /// Asynchronously retrieves an existing server side agent, wrapped as a using the provided . - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The options for creating the agent. Cannot be . - /// A factory function to customize the creation of the chat client used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A to cancel the operation if needed. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . - [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] - public static async Task GetAIAgentAsync( - this AIProjectClient aiProjectClient, - ChatClientAgentOptions options, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - - if (string.IsNullOrWhiteSpace(options.Name)) - { - throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); - } - - ThrowIfInvalidAgentName(options.Name); - - AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, options.Name, cancellationToken).ConfigureAwait(false); - var agentVersion = agentRecord.GetLatestVersion(); - - var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: !options.UseProvidedChatClientAsIs); - - return new FoundryAgent( - aiProjectClient, - AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); - } - - /// - /// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a . - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The name for the agent. - /// The name of the model to use for the agent. Cannot be or whitespace. - /// The instructions that guide the agent's behavior. Cannot be or whitespace. - /// The description for the agent. - /// The tools to use when interacting with the agent, this is required when using prompt agent definitions with tools. - /// A factory function to customize the creation of the chat client used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A token to monitor for cancellation requests. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when , , or is . - /// Thrown when or is empty or whitespace. - /// When using prompt agent definitions with tools the parameter needs to be provided. - [Obsolete("Use native AIProjectClient.Agents APIs instead.")] - public static Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - string model, - string instructions, - string? description = null, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - ThrowIfInvalidAgentName(name); - Throw.IfNullOrWhitespace(model); - Throw.IfNullOrWhitespace(instructions); - - return CreateAIAgentAsync( - aiProjectClient, - name, - tools, - new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, - clientFactory, - services, - cancellationToken); - } - - /// - /// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a . - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The name of the model to use for the agent. Cannot be or whitespace. - /// The options for creating the agent. Cannot be . - /// A factory function to customize the creation of the chat client used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A to cancel the operation if needed. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . - /// Thrown when is empty or whitespace, or when the agent name is not provided in the options. - [Obsolete("Use native AIProjectClient.Agents APIs instead.")] - public static async Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string model, - ChatClientAgentOptions options, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - Throw.IfNullOrWhitespace(model); - - if (string.IsNullOrWhiteSpace(options.Name)) - { - throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); - } - - ThrowIfInvalidAgentName(options.Name); - - AgentVersion agentVersion = await CreateAgentVersionFromOptionsAsync(aiProjectClient, model, options, cancellationToken).ConfigureAwait(false); - - var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); - - return new FoundryAgent( - aiProjectClient, - AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); - } - - /// - /// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a . - /// parameters. - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The name for the agent. - /// Settings that control the creation of the agent. - /// A factory function to customize the creation of the chat client used by the agent. - /// A token to monitor for cancellation requests. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . - /// - /// When using this extension method with a the tools are only declarative and not invocable. - /// Invocation of any in-process tools will need to be handled manually. - /// - [Obsolete("Use native AIProjectClient.Agents APIs instead.")] - public static Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - AgentVersionCreationOptions creationOptions, - Func? clientFactory = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - ThrowIfInvalidAgentName(name); - Throw.IfNull(creationOptions); - - return CreateAIAgentAsync( - aiProjectClient, - name, - tools: null, - creationOptions, - clientFactory, - services: null, - cancellationToken); - } - - /// - /// Creates a non-versioned backed by the project's Responses API using the specified model and instructions. - /// - /// The to use for Responses API calls. Cannot be . - /// The model deployment name to use for the agent. Cannot be or whitespace. - /// The instructions that guide the agent's behavior. Cannot be or whitespace. - /// Optional name for the agent. - /// Optional human-readable description for the agent. - /// Optional collection of tools that the agent can invoke during conversations. - /// Provides a way to customize the creation of the underlying used by the agent. - /// Optional logger factory for creating loggers used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A backed by the project's Responses API. - /// Thrown when is . - /// Thrown when or is empty or whitespace. - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - string model, - string instructions, - string? name = null, - string? description = null, - IList? tools = null, - Func? clientFactory = null, - ILoggerFactory? loggerFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNullOrWhitespace(model); - Throw.IfNullOrWhitespace(instructions); - - ChatClientAgentOptions options = new() - { - Name = name, - Description = description, - ChatOptions = new ChatOptions - { - ModelId = model, - Instructions = instructions, - Tools = tools, - }, - }; - - return new FoundryAgent(aiProjectClient, CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services)); - } - - /// - /// Creates a non-versioned backed by the project's Responses API using the specified options. - /// - /// The to use for Responses API calls. Cannot be . - /// Configuration options that control the agent's behavior. is required. - /// Provides a way to customize the creation of the underlying used by the agent. - /// Optional logger factory for creating loggers used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A backed by the project's Responses API. - /// Thrown when or is . - /// Thrown when does not specify . - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - ChatClientAgentOptions options, - Func? clientFactory = null, - ILoggerFactory? loggerFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - - return new FoundryAgent(aiProjectClient, CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services)); - } - - #region Private - - private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W"); - - /// - /// Asynchronously retrieves an agent record by name using the protocol method to inject user-agent headers. - /// - internal static async Task GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken) - { - ClientResult protocolResponse = await aiProjectClient.Agents.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); - var rawResponse = protocolResponse.GetRawResponse(); - AgentRecord? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); - return result ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found."); - } - - /// - /// Asynchronously creates an agent version using the protocol method to inject user-agent headers. - /// - internal static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) - { - BinaryData serializedOptions = ModelReaderWriter.Write(creationOptions, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); - BinaryContent content = BinaryContent.Create(serializedOptions); - ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, content, foundryFeatures: null, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); - var rawResponse = protocolResponse.GetRawResponse(); - AgentVersion? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); - return result ?? throw new InvalidOperationException($"Failed to create agent version for agent '{agentName}'."); - } - - private static async Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - IList? tools, - AgentVersionCreationOptions creationOptions, - Func? clientFactory, - IServiceProvider? services, - CancellationToken cancellationToken) - { - var allowDeclarativeMode = tools is not { Count: > 0 }; - - if (!allowDeclarativeMode) - { - ApplyToolsToAgentDefinition(creationOptions.Definition, tools); - } - - AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, name, creationOptions, cancellationToken).ConfigureAwait(false); - - return new FoundryAgent(aiProjectClient, AsChatClientAgent(aiProjectClient, agentVersion, tools, clientFactory, !allowDeclarativeMode, services)); - } - - /// - /// Creates an agent version with optional tool application, using the protocol method to inject user-agent headers. - /// - internal static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, IList? tools, CancellationToken cancellationToken) - { - if (tools is { Count: > 0 }) - { - ApplyToolsToAgentDefinition(creationOptions.Definition, tools); - } - - return await CreateAgentVersionWithProtocolAsync(aiProjectClient, agentName, creationOptions, cancellationToken).ConfigureAwait(false); - } - - /// - /// Creates an agent version from , mapping options to a . - /// - internal static async Task CreateAgentVersionFromOptionsAsync( - AIProjectClient aiProjectClient, - string model, - ChatClientAgentOptions options, - CancellationToken cancellationToken) - { - PromptAgentDefinition agentDefinition = new(model) - { - Instructions = options.ChatOptions?.Instructions, - Temperature = options.ChatOptions?.Temperature, - TopP = options.ChatOptions?.TopP, - TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } - }; - - if (options.ChatOptions?.Reasoning is { } reasoning) - { - agentDefinition.ReasoningOptions = ToResponseReasoningOptions(reasoning); - } - else if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions) - { - agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions; - } - - ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); - - AgentVersionCreationOptions creationOptions = new(agentDefinition); - if (!string.IsNullOrWhiteSpace(options.Description)) - { - creationOptions.Description = options.Description; - } - - return await CreateAgentVersionWithProtocolAsync(aiProjectClient, options.Name!, creationOptions, cancellationToken).ConfigureAwait(false); - } - - /// Creates a with the specified options. - internal static ChatClientAgent CreateChatClientAgent( - AIProjectClient aiProjectClient, - AgentVersion agentVersion, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, services: services); - } - - internal static ChatClientAgent CreateResponsesChatClientAgent( - AIProjectClient aiProjectClient, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - ILoggerFactory? loggerFactory, - IServiceProvider? services) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentOptions); - Throw.IfNull(agentOptions.ChatOptions); - Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); - - IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); - } - - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient aiProjectClient, - AgentVersion agentVersion, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - => CreateChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services); - - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient aiProjectClient, - AgentRecord agentRecord, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, services: services); - } - - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient aiProjectClient, - AgentReference agentReference, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, services: services); - } - - /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient AIProjectClient, - AgentVersion agentVersion, - IList? tools, - Func? clientFactory, - bool requireInvocableTools, - IServiceProvider? services) - => AsChatClientAgent( - AIProjectClient, - agentVersion, - CreateChatClientAgentOptions(agentVersion, new ChatOptions() { Tools = tools }, requireInvocableTools), - clientFactory, - services); - - /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient AIProjectClient, - AgentRecord agentRecord, - IList? tools, - Func? clientFactory, - bool requireInvocableTools, - IServiceProvider? services) - => AsChatClientAgent( - AIProjectClient, - agentRecord, - CreateChatClientAgentOptions(agentRecord.GetLatestVersion(), new ChatOptions() { Tools = tools }, requireInvocableTools), - clientFactory, - services); - - /// - /// This method creates for the specified and the provided tools. - /// - /// The agent version. - /// The to use when interacting with the agent. - /// Indicates whether to enforce the presence of invocable tools when the AIAgent is created with an agent definition that uses them. - /// The created . - /// Thrown when the agent definition requires in-process tools but none were provided. - /// Thrown when the agent definition required tools were not provided. - /// - /// This method rebuilds the agent options from the agent definition returned by the version and combine with the in-proc tools when provided - /// this ensures that all required tools are provided and the definition of the agent options are consistent with the agent definition coming from the server. - /// - internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) - { - var agentDefinition = agentVersion.Definition; - - List? agentTools = null; - if (agentDefinition is PromptAgentDefinition { Tools: { Count: > 0 } definitionTools }) - { - // Check if no tools were provided while the agent definition requires in-proc tools. - if (requireInvocableTools && chatOptions?.Tools is not { Count: > 0 } && definitionTools.Any(t => t is FunctionTool)) - { - throw new ArgumentException("The agent definition in-process tools must be provided in the extension method tools parameter."); - } - - // Agregate all missing tools for a single error message. - List? missingTools = null; - - // Check function tools - foreach (ResponseTool responseTool in definitionTools) - { - if (responseTool is FunctionTool functionTool) - { - // Check if a tool with the same type and name exists in the provided tools. - // Always prefer matching AIFunction when available, regardless of requireInvocableTools. - var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => t is AIFunction tf && functionTool.FunctionName == tf.Name); - - if (matchingTool is not null) - { - (agentTools ??= []).Add(matchingTool!); - continue; - } - - if (requireInvocableTools) - { - (missingTools ??= []).Add($"Function tool: {functionTool.FunctionName}"); - continue; - } - } - - (agentTools ??= []).Add(responseTool.AsAITool()); - } - - if (requireInvocableTools && missingTools is { Count: > 0 }) - { - throw new InvalidOperationException($"The following prompt agent definition required tools were not provided: {string.Join(", ", missingTools)}"); - } - } - - // Use the agent version's ID if available, otherwise generate one from name and version. - // This handles cases where hosted agents (like MCP agents) may not have an ID assigned. - var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version; - var agentId = string.IsNullOrWhiteSpace(agentVersion.Id) - ? $"{agentVersion.Name}:{version}" - : agentVersion.Id; - - var agentOptions = new ChatClientAgentOptions() - { - Id = agentId, - Name = agentVersion.Name, - Description = agentVersion.Description, - }; - - if (agentDefinition is PromptAgentDefinition promptAgentDefinition) - { - agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); - agentOptions.ChatOptions.Instructions = promptAgentDefinition.Instructions; - agentOptions.ChatOptions.Temperature = promptAgentDefinition.Temperature; - agentOptions.ChatOptions.TopP = promptAgentDefinition.TopP; - } - - if (agentTools is { Count: > 0 }) - { - agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); - agentOptions.ChatOptions.Tools = agentTools; - } - - return agentOptions; - } - - /// - /// Creates a new instance of configured for the specified agent version and - /// optional base options. - /// - /// The agent version to use when configuring the chat client agent options. - /// An optional instance whose relevant properties will be copied to the - /// returned options. If , only default values are used. - /// Specifies whether the returned options must include invocable tools. Set to to require - /// invocable tools; otherwise, . - /// A instance configured according to the specified parameters. - internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatClientAgentOptions? options, bool requireInvocableTools) - { - var agentOptions = CreateChatClientAgentOptions(agentVersion, options?.ChatOptions, requireInvocableTools); - if (options is not null) - { - agentOptions.AIContextProviders = options.AIContextProviders; - agentOptions.ChatHistoryProvider = options.ChatHistoryProvider; - agentOptions.UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs; - } - - return agentOptions; - } - - /// - /// Adds the specified AI tools to a prompt agent definition, while also ensuring that all invocable tools are provided. - /// - /// The agent definition to which the tools will be applied. Must be a PromptAgentDefinition to support tools. - /// A list of AI tools to add to the agent definition. If null or empty, no tools are added. - /// Thrown if tools were provided but is not a . - /// When providing functions, they need to be invokable AIFunctions. - private static void ApplyToolsToAgentDefinition(AgentDefinition agentDefinition, IList? tools) - { - if (tools is { Count: > 0 }) - { - if (agentDefinition is not PromptAgentDefinition promptAgentDefinition) - { - throw new ArgumentException("Only prompt agent definitions support tools.", nameof(agentDefinition)); - } - - // When tools are provided, those should represent the complete set of tools for the agent definition. - // This is particularly important for existing agents so no duplication happens for what was already defined. - promptAgentDefinition.Tools.Clear(); - - foreach (var tool in tools) - { - // Ensure that any AIFunctions provided are In-Proc, not just the declarations. - if (tool is not AIFunction && ( - tool.GetService() is not null // Declarative FunctionTool converted as AsAITool() - || tool is AIFunctionDeclaration)) // AIFunctionDeclaration type - { - throw new InvalidOperationException("When providing functions, they need to be invokable AIFunctions. AIFunctions can be created correctly using AIFunctionFactory.Create"); - } - - promptAgentDefinition.Tools.Add( - // If this is a converted ResponseTool as AITool, we can directly retrieve the ResponseTool instance from GetService. - tool.GetService() - // Otherwise we should be able to convert existing MEAI Tool abstractions into OpenAI ResponseTools - ?? tool.AsOpenAIResponseTool() - ?? throw new InvalidOperationException("The provided AITool could not be converted to a ResponseTool, ensure that the AITool was created using responseTool.AsAITool() extension.")); - } - } - } - - private static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) => - format switch - { - ChatResponseFormatText => ResponseTextFormat.CreateTextFormat(), - - ChatResponseFormatJson jsonFormat when StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema => - ResponseTextFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AgentClientJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription, - HasStrict(options?.AdditionalProperties)), - - ChatResponseFormatJson => ResponseTextFormat.CreateJsonObjectFormat(), - - _ => null, - }; - - /// Key into AdditionalProperties used to store a strict option. - private const string StrictKey = "strictJsonSchema"; - - /// Gets whether the properties specify that strict schema handling is desired. - private static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => - additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && - strictObj is bool strictValue ? - strictValue : null; - - /// - /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per - /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. - /// - private static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() - { - DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true, - MoveDefaultKeywordToDescription = true, - RequireAllProperties = true, - TransformSchemaNode = (ctx, node) => - { - // Move content from common but unsupported properties to description. In particular, we focus on properties that - // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. - - if (node is JsonObject schemaObj) - { - StringBuilder? additionalDescription = null; - - ReadOnlySpan unsupportedProperties = - [ - // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: - "contentEncoding", "contentMediaType", "not", - - // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: - "minLength", "maxLength", "pattern", "format", - "minimum", "maximum", "multipleOf", - "patternProperties", - "minItems", "maxItems", - - // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords - // as being unsupported with Azure OpenAI: - "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", - "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", - ]; - - foreach (string propName in unsupportedProperties) - { - if (schemaObj[propName] is { } propNode) - { - _ = schemaObj.Remove(propName); - AppendLine(ref additionalDescription, propName, propNode); - } - } - - if (additionalDescription is not null) - { - schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? - $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : - additionalDescription.ToString(); - } - - return node; - - static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) - { - sb ??= new(); - - if (sb.Length > 0) - { - _ = sb.AppendLine(); - } - - _ = sb.Append(propName).Append(": ").Append(propNode); - } - } - - return node; - }, - }); - - /// - /// This class is a no-op implementation of to be used to honor the argument passed - /// while triggering avoiding any unexpected exception on the caller implementation. - /// - private sealed class NoOpChatClient : IChatClient - { - public void Dispose() { } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse()); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - yield return new ChatResponseUpdate(); - } - } - #endregion - -#if NET - [GeneratedRegex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$")] - private static partial Regex AgentNameValidationRegex(); -#else - private static Regex AgentNameValidationRegex() => new("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); -#endif - - internal static string ThrowIfInvalidAgentName(string? name) - { - Throw.IfNullOrWhitespace(name); - if (!AgentNameValidationRegex().IsMatch(name)) - { - throw new ArgumentException("Agent name must be 1-63 characters long, start and end with an alphanumeric character, and can only contain alphanumeric characters or hyphens.", nameof(name)); - } - return name; - } - - private static ResponseReasoningOptions? ToResponseReasoningOptions(ReasoningOptions reasoning) - { - ResponseReasoningEffortLevel? effortLevel = reasoning.Effort switch - { - ReasoningEffort.Low => ResponseReasoningEffortLevel.Low, - ReasoningEffort.Medium => ResponseReasoningEffortLevel.Medium, - ReasoningEffort.High => ResponseReasoningEffortLevel.High, - ReasoningEffort.ExtraHigh => ResponseReasoningEffortLevel.High, - _ => null, - }; - - ResponseReasoningSummaryVerbosity? summary = reasoning.Output switch - { - ReasoningOutput.Summary => ResponseReasoningSummaryVerbosity.Concise, - ReasoningOutput.Full => ResponseReasoningSummaryVerbosity.Detailed, - _ => null, - }; - - if (effortLevel is null && summary is null) - { - return null; - } - - return new ResponseReasoningOptions - { - ReasoningEffortLevel = effortLevel, - ReasoningSummaryVerbosity = summary, - }; - } -} - -[JsonSerializable(typeof(JsonElement))] -internal sealed partial class AgentClientJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs index ec788233ed..c9a121bfc4 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.AI.Projects.Agents; @@ -10,7 +14,7 @@ using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI.Responses; -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using @@ -21,8 +25,8 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient { private readonly ChatClientMetadata? _metadata; private readonly AIProjectClient _agentClient; - private readonly AgentVersion? _agentVersion; - private readonly AgentRecord? _agentRecord; + private readonly ProjectsAgentVersion? _agentVersion; + private readonly ProjectsAgentRecord? _agentRecord; private readonly ChatOptions? _chatOptions; private readonly AgentReference _agentReference; @@ -52,34 +56,34 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient /// Initializes a new instance of the class. /// /// An instance of to interact with Azure AI Agents services. - /// An instance of representing the specific agent to use. + /// An instance of representing the specific agent to use. /// An instance of representing the options on how the agent was predefined. /// /// The provided should be decorated with a for proper functionality. /// - internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentRecord agentRecord, ChatOptions? chatOptions) + internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? chatOptions) : this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), chatOptions) { this._agentRecord = agentRecord; } - internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentVersion agentVersion, ChatOptions? chatOptions) + internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? chatOptions) : this( aiProjectClient, CreateAgentReference(Throw.IfNull(agentVersion)), - (agentVersion.Definition as PromptAgentDefinition)?.Model, + (agentVersion.Definition as DeclarativeAgentDefinition)?.Model, chatOptions) { this._agentVersion = agentVersion; } /// - /// Creates an from an . + /// Creates an from an . /// Uses the agent version's version if available, otherwise defaults to "latest". /// /// The agent version to create a reference from. /// An for the specified agent version. - private static AgentReference CreateAgentReference(AgentVersion agentVersion) + private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion) { // If the version is null, empty, or whitespace, use "latest" as the default. // This handles cases where hosted agents (like MCP agents) may not have a version assigned. @@ -94,9 +98,9 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient ? this._metadata : (serviceKey is null && serviceType == typeof(AIProjectClient)) ? this._agentClient - : (serviceKey is null && serviceType == typeof(AgentVersion)) + : (serviceKey is null && serviceType == typeof(ProjectsAgentVersion)) ? this._agentVersion - : (serviceKey is null && serviceType == typeof(AgentRecord)) + : (serviceKey is null && serviceType == typeof(ProjectsAgentRecord)) ? this._agentRecord : (serviceKey is null && serviceType == typeof(AgentReference)) ? this._agentReference diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs new file mode 100644 index 0000000000..4383cfb6d4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs @@ -0,0 +1,436 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects.Agents; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Azure.AI.Projects; + +/// +/// Provides extension methods for . +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static partial class AzureAIProjectChatClientExtensions +{ + /// + /// Uses an existing server side agent, wrapped as a using the provided and . + /// + /// The to create the with. Cannot be . + /// The representing the name and version of the server side agent to create a for. Cannot be . + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. + /// Thrown when or is . + /// The agent with the specified name was not found. + /// + /// When instantiating a by using an , minimal information will be available about the agent in the instance level, and any logic that relies + /// on to retrieve information about the agent like will receive as the result. + /// + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + AgentReference agentReference, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentReference); + ThrowIfInvalidAgentName(agentReference.Name); + + var innerAgent = AsChatClientAgent( + aiProjectClient, + agentReference, + new ChatClientAgentOptions() + { + Id = $"{agentReference.Name}:{agentReference.Version}", + Name = agentReference.Name, + ChatOptions = new() { Tools = tools }, + }, + clientFactory, + services); + + return new FoundryAgent(aiProjectClient, innerAgent); + } + + /// + /// Uses an existing server side agent, wrapped as a using the provided and . + /// + /// The client used to interact with Azure AI Agents. Cannot be . + /// The agent record to be converted. The latest version will be used. Cannot be . + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the latest version of the Azure AI Agent. + /// Thrown when or is . + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + ProjectsAgentRecord agentRecord, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentRecord); + + var allowDeclarativeMode = tools is not { Count: > 0 }; + + var innerAgent = AsChatClientAgent( + aiProjectClient, + agentRecord, + tools, + clientFactory, + !allowDeclarativeMode, + services); + + return new FoundryAgent(aiProjectClient, innerAgent); + } + + /// + /// Uses an existing server side agent, wrapped as a using the provided and . + /// + /// The client used to interact with Azure AI Agents. Cannot be . + /// The agent version to be converted. Cannot be . + /// In-process invocable tools to be provided. If no tools are provided manual handling will be necessary to invoke in-process tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the provided version of the Azure AI Agent. + /// Thrown when or is . + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + ProjectsAgentVersion agentVersion, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentVersion); + + var allowDeclarativeMode = tools is not { Count: > 0 }; + + var innerAgent = AsChatClientAgent( + aiProjectClient, + agentVersion, + tools, + clientFactory, + !allowDeclarativeMode, + services); + + return new FoundryAgent(aiProjectClient, innerAgent); + } + + /// + /// Creates a non-versioned backed by the project's Responses API using the specified model and instructions. + /// + /// The to use for Responses API calls. Cannot be . + /// The model deployment name to use for the agent. Cannot be or whitespace. + /// The instructions that guide the agent's behavior. Cannot be or whitespace. + /// Optional name for the agent. + /// Optional human-readable description for the agent. + /// Optional collection of tools that the agent can invoke during conversations. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for creating loggers used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A backed by the project's Responses API. + /// Thrown when is . + /// Thrown when or is empty or whitespace. + public static ChatClientAgent AsAIAgent( + this AIProjectClient aiProjectClient, + string model, + string instructions, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNullOrWhitespace(model); + Throw.IfNullOrWhitespace(instructions); + + ChatClientAgentOptions options = new() + { + Name = name, + Description = description, + ChatOptions = new ChatOptions + { + ModelId = model, + Instructions = instructions, + Tools = tools, + }, + }; + + return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + /// + /// Creates a non-versioned backed by the project's Responses API using the specified options. + /// + /// The to use for Responses API calls. Cannot be . + /// Configuration options that control the agent's behavior. is required. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for creating loggers used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A backed by the project's Responses API. + /// Thrown when or is . + /// Thrown when does not specify . + public static ChatClientAgent AsAIAgent( + this AIProjectClient aiProjectClient, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(options); + + return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + #region Private + + /// Creates a with the specified options. + private static ChatClientAgent CreateChatClientAgent( + AIProjectClient aiProjectClient, + ProjectsAgentVersion agentVersion, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + private static ChatClientAgent CreateResponsesChatClientAgent( + AIProjectClient aiProjectClient, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + ILoggerFactory? loggerFactory, + IServiceProvider? services) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentOptions); + Throw.IfNull(agentOptions.ChatOptions); + Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); + + IChatClient chatClient = aiProjectClient + .GetProjectOpenAIClient() + .GetResponsesClient() + .AsIChatClient(agentOptions.ChatOptions.ModelId); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + ProjectsAgentVersion agentVersion, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + => CreateChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services); + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + ProjectsAgentRecord agentRecord, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + AgentReference agentReference, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + ProjectsAgentVersion agentVersion, + IList? tools, + Func? clientFactory, + bool requireInvocableTools, + IServiceProvider? services) + => AsChatClientAgent( + aiProjectClient, + agentVersion, + CreateChatClientAgentOptions(agentVersion, new ChatOptions() { Tools = tools }, requireInvocableTools), + clientFactory, + services); + + /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + ProjectsAgentRecord agentRecord, + IList? tools, + Func? clientFactory, + bool requireInvocableTools, + IServiceProvider? services) + => AsChatClientAgent( + aiProjectClient, + agentRecord, + CreateChatClientAgentOptions(agentRecord.GetLatestVersion(), new ChatOptions() { Tools = tools }, requireInvocableTools), + clientFactory, + services); + + /// + /// This method creates for the specified and the provided tools. + /// + /// The agent version. + /// The to use when interacting with the agent. + /// Indicates whether to enforce the presence of invocable tools when the AIAgent is created with an agent definition that uses them. + /// The created . + /// Thrown when the agent definition requires in-process tools but none were provided. + /// Thrown when the agent definition required tools were not provided. + /// + /// This method rebuilds the agent options from the agent definition returned by the version and combine with the in-proc tools when provided + /// this ensures that all required tools are provided and the definition of the agent options are consistent with the agent definition coming from the server. + /// + private static ChatClientAgentOptions CreateChatClientAgentOptions(ProjectsAgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) + { + var agentDefinition = agentVersion.Definition; + + List? agentTools = null; + if (agentDefinition is DeclarativeAgentDefinition { Tools: { Count: > 0 } definitionTools }) + { + // Check if no tools were provided while the agent definition requires in-proc tools. + if (requireInvocableTools && chatOptions?.Tools is not { Count: > 0 } && definitionTools.Any(t => t is FunctionTool)) + { + throw new ArgumentException("The agent definition in-process tools must be provided in the extension method tools parameter."); + } + + // Agregate all missing tools for a single error message. + List? missingTools = null; + + // Check function tools + foreach (ResponseTool responseTool in definitionTools) + { + if (responseTool is FunctionTool functionTool) + { + // Check if a tool with the same type and name exists in the provided tools. + // Always prefer matching AIFunction when available, regardless of requireInvocableTools. + var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => t is AIFunction tf && functionTool.FunctionName == tf.Name); + + if (matchingTool is not null) + { + (agentTools ??= []).Add(matchingTool!); + continue; + } + + if (requireInvocableTools) + { + (missingTools ??= []).Add($"Function tool: {functionTool.FunctionName}"); + continue; + } + } + + (agentTools ??= []).Add(responseTool.AsAITool()); + } + + if (requireInvocableTools && missingTools is { Count: > 0 }) + { + throw new InvalidOperationException($"The following prompt agent definition required tools were not provided: {string.Join(", ", missingTools)}"); + } + } + + // Use the agent version's ID if available, otherwise generate one from name and version. + // This handles cases where hosted agents (like MCP agents) may not have an ID assigned. + var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version; + var agentId = string.IsNullOrWhiteSpace(agentVersion.Id) + ? $"{agentVersion.Name}:{version}" + : agentVersion.Id; + + var agentOptions = new ChatClientAgentOptions() + { + Id = agentId, + Name = agentVersion.Name, + Description = agentVersion.Description, + }; + + if (agentDefinition is DeclarativeAgentDefinition promptAgentDefinition) + { + agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); + agentOptions.ChatOptions.Instructions = promptAgentDefinition.Instructions; + agentOptions.ChatOptions.Temperature = promptAgentDefinition.Temperature; + agentOptions.ChatOptions.TopP = promptAgentDefinition.TopP; + } + + if (agentTools is { Count: > 0 }) + { + agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); + agentOptions.ChatOptions.Tools = agentTools; + } + + return agentOptions; + } + +#if NET + [GeneratedRegex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$")] + private static partial Regex AgentNameValidationRegex(); +#else + private static Regex AgentNameValidationRegex() => new("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); +#endif + + internal static string ThrowIfInvalidAgentName(string? name) + { + Throw.IfNullOrWhitespace(name); + if (!AgentNameValidationRegex().IsMatch(name)) + { + throw new ArgumentException("Agent name must be 1-63 characters long, start and end with an alphanumeric character, and can only contain alphanumeric characters or hyphens.", nameof(name)); + } + return name; + } +} + +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class AgentClientJsonContext : JsonSerializerContext; + +#endregion diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs similarity index 95% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs index 48bb20d766..a768d102f8 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs @@ -1,10 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Azure.AI.Projects; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; #pragma warning disable OPENAI001 internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/CompatibilitySuppressions.xml rename to dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs similarity index 90% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs index 80ed48e1df..7721f8c013 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Azure.AI.Projects.Agents; using Microsoft.Extensions.AI; @@ -8,19 +10,19 @@ using OpenAI.Responses; #pragma warning disable OPENAI001 -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides factory methods for creating instances from Microsoft Foundry and OpenAI response tools. /// /// /// -/// This class wraps (Azure.AI.Projects.OpenAI) and (OpenAI SDK) factory methods, +/// This class wraps (Azure.AI.Projects.Agents) and (OpenAI SDK) factory methods, /// returning directly — eliminating the need for manual casting and .AsAITool() calls. /// /// /// Instead of writing: -/// ((ResponseTool)AgentTool.CreateOpenApiTool(definition)).AsAITool() +/// ((ResponseTool)ProjectsAgentTool.CreateOpenApiTool(definition)).AsAITool() /// You can write: /// FoundryAITool.CreateOpenApiTool(definition) /// @@ -35,7 +37,7 @@ public static class FoundryAITool /// An wrapping the provided response tool. public static AITool FromResponseTool(ResponseTool responseTool) => responseTool.AsAITool(); - // --- Azure.AI.Projects.OpenAI AgentTool factories --- + // --- Azure.AI.Projects.OpenAI ProjectsAgentTool factories --- /// /// Creates an for OpenAPI tool invocations. @@ -43,7 +45,7 @@ public static class FoundryAITool /// The OpenAPI function definition specifying the API endpoint, schema, and authentication. /// An that calls the specified OpenAPI endpoint. public static AITool CreateOpenApiTool(OpenApiFunctionDefinition definition) - => ((ResponseTool)AgentTool.CreateOpenApiTool(definition)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateOpenApiTool(definition)).AsAITool(); /// /// Creates an for Bing Grounding search. @@ -51,7 +53,7 @@ public static class FoundryAITool /// The Bing Grounding search configuration options. /// An for Bing Grounding search. public static AITool CreateBingGroundingTool(BingGroundingSearchToolOptions options) - => ((ResponseTool)AgentTool.CreateBingGroundingTool(options)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateBingGroundingTool(options)).AsAITool(); /// /// Creates an for Bing Custom Search. @@ -59,7 +61,7 @@ public static class FoundryAITool /// The Bing Custom Search configuration parameters. /// An for Bing Custom Search. public static AITool CreateBingCustomSearchTool(BingCustomSearchToolOptions parameters) - => ((ResponseTool)AgentTool.CreateBingCustomSearchTool(parameters)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateBingCustomSearchTool(parameters)).AsAITool(); /// /// Creates an for Microsoft Fabric data agent. @@ -67,7 +69,7 @@ public static class FoundryAITool /// The Fabric data agent configuration options. /// An for Microsoft Fabric. public static AITool CreateMicrosoftFabricTool(FabricDataAgentToolOptions options) - => ((ResponseTool)AgentTool.CreateMicrosoftFabricTool(options)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateMicrosoftFabricTool(options)).AsAITool(); /// /// Creates an for SharePoint grounding. @@ -75,7 +77,7 @@ public static class FoundryAITool /// The SharePoint grounding configuration options. /// An for SharePoint grounding. public static AITool CreateSharepointTool(SharePointGroundingToolOptions options) - => ((ResponseTool)AgentTool.CreateSharepointTool(options)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateSharepointTool(options)).AsAITool(); /// /// Creates an for Azure AI Search. @@ -83,7 +85,7 @@ public static class FoundryAITool /// Optional Azure AI Search configuration options. /// An for Azure AI Search. public static AITool CreateAzureAISearchTool(AzureAISearchToolOptions? options = null) - => ((ResponseTool)AgentTool.CreateAzureAISearchTool(options)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateAzureAISearchTool(options)).AsAITool(); /// /// Creates an for browser automation. @@ -91,7 +93,7 @@ public static class FoundryAITool /// The browser automation configuration parameters. /// An for browser automation. public static AITool CreateBrowserAutomationTool(BrowserAutomationToolOptions parameters) - => ((ResponseTool)AgentTool.CreateBrowserAutomationTool(parameters)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateBrowserAutomationTool(parameters)).AsAITool(); /// /// Creates an for structured output capture. @@ -99,7 +101,7 @@ public static class FoundryAITool /// The structured output definition. /// An for structured output capture. public static AITool CreateStructuredOutputsTool(StructuredOutputDefinition outputs) - => ((ResponseTool)AgentTool.CreateStructuredOutputsTool(outputs)).AsAITool(); + => ((ResponseTool)ProjectsAgentTool.CreateStructuredOutputsTool(outputs)).AsAITool(); /// /// Creates an for Agent-to-Agent (A2A) communication. @@ -108,7 +110,7 @@ public static class FoundryAITool /// Optional path to the agent card. /// An for A2A communication. public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null) - => AgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool(); + => ProjectsAgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool(); // --- OpenAI SDK ResponseTool factories --- diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index 66fa93e39f..b1d97a5c9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.ClientModel; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Extensions.AI; @@ -9,7 +13,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides an that uses Microsoft Foundry for AI agent capabilities. @@ -163,7 +167,29 @@ public sealed class FoundryAgent : DelegatingAIAgent }, }; - return AzureAIProjectChatClientExtensions.CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + private static ChatClientAgent CreateResponsesChatClientAgent( + AIProjectClient aiProjectClient, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + ILoggerFactory? loggerFactory, + IServiceProvider? services) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentOptions); + Throw.IfNull(agentOptions.ChatOptions); + Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); + + IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); } private static ChatClientAgent CreateInnerAgentFromEndpoint( diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryJsonUtilities.cs similarity index 84% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryJsonUtilities.cs index 1a0dd4f4e2..1ed4046de0 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryJsonUtilities.cs @@ -1,13 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides JSON serialization utilities for the Foundry Memory provider. /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal static class FoundryMemoryJsonUtilities { /// @@ -33,4 +36,5 @@ internal static class FoundryMemoryJsonUtilities WriteIndented = false)] [JsonSerializable(typeof(FoundryMemoryProviderScope))] [JsonSerializable(typeof(FoundryMemoryProvider.State))] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal partial class FoundryMemoryJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs index 93b343b2de..ffae51cefc 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs @@ -9,6 +9,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; +using Azure.AI.Projects.Memory; using Microsoft.Extensions.AI; using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; @@ -16,7 +17,7 @@ using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI.Responses; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides a Microsoft Foundry Memory backed that persists conversation messages as memories @@ -27,7 +28,7 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages /// to the model, prefixed by a configurable context prompt. /// -[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryMemoryProvider : AIContextProvider { private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderOptions.cs similarity index 95% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderOptions.cs index cf4fb5ab15..668db44112 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderOptions.cs @@ -2,14 +2,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Shared.DiagnosticIds; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Options for configuring the . /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryMemoryProviderOptions { /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderScope.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderScope.cs index 6646c482a3..769aff7370 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderScope.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Allows scoping of memories for the . @@ -13,6 +15,7 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// Common patterns include using a user ID, team ID, or other unique identifier /// to partition memories across different contexts. /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryMemoryProviderScope { /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/MemoryStoreExtensions.cs similarity index 91% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/MemoryStoreExtensions.cs index 9e24703d92..3d988639e8 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/MemoryStoreExtensions.cs @@ -4,13 +4,14 @@ using System.ClientModel; using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; +using Azure.AI.Projects.Memory; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Internal extension methods for to provide MemoryStores helper operations. /// -internal static class AIProjectClientExtensions +internal static class MemoryStoreExtensions { /// /// Creates a memory store if it doesn't already exist. diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj similarity index 61% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj rename to dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 0cd8690126..670d140043 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -1,22 +1,30 @@ - true - enable + true true + $(NoWarn);OPENAI001 + + + + false + + true true + true + @@ -30,4 +38,9 @@ Provides Microsoft Agent Framework support for Foundry Agents. + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ProjectResponsesClientExtensions.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/ProjectResponsesClientExtensions.cs index 5a899d5076..cd253776a8 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ProjectResponsesClientExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs index 2705611b57..03e48e293b 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System.ClientModel.Primitives; +using System.Collections.Generic; using System.Reflection; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.Agents.AI; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj deleted file mode 100644 index 7abc3d0bcc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - preview - $(NoWarn);OPENAI001 - - - - true - true - true - true - true - - - - - - - - - - - - - - - - - Microsoft Agent Framework - Azure AI Foundry Memory integration - Provides Azure AI Foundry Memory integration for Microsoft Agent Framework. - - - - - - - - diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml index fe04c26eca..1ebb184fbc 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml @@ -2,106 +2,36 @@ - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) + CP0001 + T:OpenAI.Assistants.OpenAIAssistantClientExtensions lib/net10.0/Microsoft.Agents.AI.OpenAI.dll lib/net10.0/Microsoft.Agents.AI.OpenAI.dll true - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient) - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) + CP0001 + T:OpenAI.Assistants.OpenAIAssistantClientExtensions lib/net472/Microsoft.Agents.AI.OpenAI.dll lib/net472/Microsoft.Agents.AI.OpenAI.dll true - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) - lib/net472/Microsoft.Agents.AI.OpenAI.dll - lib/net472/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient) - lib/net472/Microsoft.Agents.AI.OpenAI.dll - lib/net472/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) + CP0001 + T:OpenAI.Assistants.OpenAIAssistantClientExtensions lib/net8.0/Microsoft.Agents.AI.OpenAI.dll lib/net8.0/Microsoft.Agents.AI.OpenAI.dll true - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient) - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) + CP0001 + T:OpenAI.Assistants.OpenAIAssistantClientExtensions lib/net9.0/Microsoft.Agents.AI.OpenAI.dll lib/net9.0/Microsoft.Agents.AI.OpenAI.dll true - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient) - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0002 - M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient) + CP0001 + T:OpenAI.Assistants.OpenAIAssistantClientExtensions lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll true diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs deleted file mode 100644 index a1f083ae06..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs +++ /dev/null @@ -1,433 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace OpenAI.Assistants; - -/// -/// Provides extension methods for OpenAI -/// to simplify the creation of AI agents that work with OpenAI services. -/// -/// -/// These extensions bridge the gap between OpenAI SDK client objects and the Microsoft Agent Framework, -/// allowing developers to easily create AI agents that leverage OpenAI's chat completion and response services. -/// The methods handle the conversion from OpenAI clients to instances and then wrap them -/// in objects that implement the interface. -/// -[Experimental(DiagnosticIds.Experiments.AIOpenAIAssistants)] -public static class OpenAIAssistantClientExtensions -{ - /// - /// Gets a from a . - /// - /// The assistant client. - /// The client result containing the assistant. - /// Optional chat options. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations on the assistant. - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static ChatClientAgent AsAIAgent( - this AssistantClient assistantClient, - ClientResult assistantClientResult, - ChatOptions? chatOptions = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - if (assistantClientResult is null) - { - throw new ArgumentNullException(nameof(assistantClientResult)); - } - - return assistantClient.AsAIAgent(assistantClientResult.Value, chatOptions, clientFactory, services); - } - - /// - /// Gets a from an . - /// - /// The assistant client. - /// The assistant metadata. - /// Optional chat options. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations on the assistant. - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static ChatClientAgent AsAIAgent( - this AssistantClient assistantClient, - Assistant assistantMetadata, - ChatOptions? chatOptions = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - if (assistantMetadata is null) - { - throw new ArgumentNullException(nameof(assistantMetadata)); - } - if (assistantClient is null) - { - throw new ArgumentNullException(nameof(assistantClient)); - } - - var chatClient = assistantClient.AsIChatClient(assistantMetadata.Id); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - if (!string.IsNullOrWhiteSpace(assistantMetadata.Instructions) && chatOptions?.Instructions is null) - { - chatOptions ??= new ChatOptions(); - chatOptions.Instructions = assistantMetadata.Instructions; - } - - return new ChatClientAgent(chatClient, options: new() - { - Id = assistantMetadata.Id, - Name = assistantMetadata.Name, - Description = assistantMetadata.Description, - ChatOptions = chatOptions - }, services: services); - } - - /// - /// Retrieves an existing server side agent, wrapped as a using the provided . - /// - /// The to create the with. - /// The ID of the server side agent to create a for. - /// Options that should apply to all runs of the agent. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// The to monitor for cancellation requests. The default is . - /// A instance that can be used to perform operations on the assistant agent. - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static async Task GetAIAgentAsync( - this AssistantClient assistantClient, - string agentId, - ChatOptions? chatOptions = null, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - if (assistantClient is null) - { - throw new ArgumentNullException(nameof(assistantClient)); - } - - if (string.IsNullOrWhiteSpace(agentId)) - { - throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); - } - - var assistantResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false); - return assistantClient.AsAIAgent(assistantResponse, chatOptions, clientFactory, services); - } - - /// - /// Gets a from a . - /// - /// The assistant client. - /// The client result containing the assistant. - /// Full set of options to configure the agent. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations on the assistant. - /// or is . - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static ChatClientAgent AsAIAgent( - this AssistantClient assistantClient, - ClientResult assistantClientResult, - ChatClientAgentOptions options, - Func? clientFactory = null, - IServiceProvider? services = null) - { - if (assistantClientResult is null) - { - throw new ArgumentNullException(nameof(assistantClientResult)); - } - - return assistantClient.AsAIAgent(assistantClientResult.Value, options, clientFactory, services); - } - - /// - /// Gets a from an . - /// - /// The assistant client. - /// The assistant metadata. - /// Full set of options to configure the agent. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations on the assistant. - /// or is . - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static ChatClientAgent AsAIAgent( - this AssistantClient assistantClient, - Assistant assistantMetadata, - ChatClientAgentOptions options, - Func? clientFactory = null, - IServiceProvider? services = null) - { - if (assistantMetadata is null) - { - throw new ArgumentNullException(nameof(assistantMetadata)); - } - - if (assistantClient is null) - { - throw new ArgumentNullException(nameof(assistantClient)); - } - - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - var chatClient = assistantClient.AsIChatClient(assistantMetadata.Id); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - if (string.IsNullOrWhiteSpace(options.ChatOptions?.Instructions) && !string.IsNullOrWhiteSpace(assistantMetadata.Instructions)) - { - options.ChatOptions ??= new ChatOptions(); - options.ChatOptions.Instructions = assistantMetadata.Instructions; - } - - var mergedOptions = new ChatClientAgentOptions() - { - Id = assistantMetadata.Id, - Name = options.Name ?? assistantMetadata.Name, - Description = options.Description ?? assistantMetadata.Description, - ChatOptions = options.ChatOptions, - AIContextProviders = options.AIContextProviders, - ChatHistoryProvider = options.ChatHistoryProvider, - UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs - }; - - return new ChatClientAgent(chatClient, mergedOptions, services: services); - } - - /// - /// Retrieves an existing server side agent, wrapped as a using the provided . - /// - /// The to create the with. - /// The ID of the server side agent to create a for. - /// Full set of options to configure the agent. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// The to monitor for cancellation requests. The default is . - /// A instance that can be used to perform operations on the assistant agent. - /// or is . - /// is empty or whitespace. - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static async Task GetAIAgentAsync( - this AssistantClient assistantClient, - string agentId, - ChatClientAgentOptions options, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - if (assistantClient is null) - { - throw new ArgumentNullException(nameof(assistantClient)); - } - - if (string.IsNullOrWhiteSpace(agentId)) - { - throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); - } - - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - var assistantResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false); - return assistantClient.AsAIAgent(assistantResponse, options, clientFactory, services); - } - - /// - /// Creates an AI agent from an using the OpenAI Assistant API. - /// - /// The OpenAI to use for the agent. - /// The model identifier to use (e.g., "gpt-4"). - /// Optional system instructions that define the agent's behavior and personality. - /// Optional name for the agent for identification purposes. - /// Optional description of the agent's capabilities and purpose. - /// Optional collection of AI tools that the agent can use during conversations. - /// Provides a way to customize the creation of the underlying used by the agent. - /// Optional logger factory for enabling logging within the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// The to monitor for cancellation requests. The default is . - /// An instance backed by the OpenAI Assistant service. - /// Thrown when or is . - /// Thrown when is empty or whitespace. - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static async Task CreateAIAgentAsync( - this AssistantClient client, - string model, - string? instructions = null, - string? name = null, - string? description = null, - IList? tools = null, - Func? clientFactory = null, - ILoggerFactory? loggerFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) => - await client.CreateAIAgentAsync(model, - new ChatClientAgentOptions() - { - Name = name, - Description = description, - ChatOptions = tools is null && string.IsNullOrWhiteSpace(instructions) ? null : new ChatOptions() - { - Tools = tools, - Instructions = instructions, - } - }, - clientFactory, - loggerFactory, - services, - cancellationToken).ConfigureAwait(false); - - /// - /// Creates an AI agent from an using the OpenAI Assistant API. - /// - /// The OpenAI to use for the agent. - /// The model identifier to use (e.g., "gpt-4"). - /// Full set of options to configure the agent. - /// Provides a way to customize the creation of the underlying used by the agent. - /// Optional logger factory for enabling logging within the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// The to monitor for cancellation requests. The default is . - /// An instance backed by the OpenAI Assistant service. - /// Thrown when or is . - /// Thrown when is empty or whitespace. - [Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")] - public static async Task CreateAIAgentAsync( - this AssistantClient client, - string model, - ChatClientAgentOptions options, - Func? clientFactory = null, - ILoggerFactory? loggerFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(client); - Throw.IfNull(model); - Throw.IfNull(options); - - var assistantOptions = new AssistantCreationOptions() - { - Name = options.Name, - Description = options.Description, - Instructions = options.ChatOptions?.Instructions, - }; - - // Convert AITools to ToolDefinitions and ToolResources - var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); - if (toolDefinitionsAndResources.ToolDefinitions is { Count: > 0 } toolDefinitions) - { - toolDefinitions.ForEach(x => assistantOptions.Tools.Add(x)); - } - if (toolDefinitionsAndResources.ToolResources is not null) - { - assistantOptions.ToolResources = toolDefinitionsAndResources.ToolResources; - } - - // Create the assistant in the assistant service. - var assistantCreateResult = await client.CreateAssistantAsync(model, assistantOptions, cancellationToken).ConfigureAwait(false); - var assistantId = assistantCreateResult.Value.Id; - - // Build the local agent object. - var chatClient = client.AsIChatClient(assistantId); - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - var agentOptions = options.Clone(); - agentOptions.Id = assistantId; - options.ChatOptions ??= new ChatOptions(); - options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; - - return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); - } - - private static (List? ToolDefinitions, ToolResources? ToolResources, List? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList? tools) - { - List? toolDefinitions = null; - ToolResources? toolResources = null; - List? functionToolsAndOtherTools = null; - - if (tools is not null) - { - foreach (AITool tool in tools) - { - switch (tool) - { - case HostedCodeInterpreterTool codeTool: - - toolDefinitions ??= []; - toolDefinitions.Add(new CodeInterpreterToolDefinition()); - - if (codeTool.Inputs is { Count: > 0 }) - { - foreach (var input in codeTool.Inputs) - { - switch (input) - { - case HostedFileContent hostedFile: - // If the input is a HostedFileContent, we can use its ID directly. - toolResources ??= new(); - toolResources.CodeInterpreter ??= new(); - toolResources.CodeInterpreter.FileIds.Add(hostedFile.FileId); - break; - } - } - } - break; - - case HostedFileSearchTool fileSearchTool: - toolDefinitions ??= []; - toolDefinitions.Add(new FileSearchToolDefinition - { - MaxResults = fileSearchTool.MaximumResultCount, - }); - - if (fileSearchTool.Inputs is { Count: > 0 }) - { - foreach (var input in fileSearchTool.Inputs) - { - switch (input) - { - case HostedVectorStoreContent hostedVectorStore: - toolResources ??= new(); - toolResources.FileSearch ??= new(); - toolResources.FileSearch.VectorStoreIds.Add(hostedVectorStore.VectorStoreId); - break; - } - } - } - break; - - default: - functionToolsAndOtherTools ??= []; - functionToolsAndOtherTools.Add(tool); - break; - } - } - } - - return (toolDefinitions, toolResources, functionToolsAndOtherTools); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj index 6bc976d33f..ed74c757c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj @@ -1,7 +1,7 @@ - true + true enable true diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs similarity index 92% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs index 6db870f8ec..98e8b7f53f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs @@ -28,7 +28,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// The credentials used to authenticate with the Foundry project. This must be a valid instance of . public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials) : ResponseAgentProvider { - private readonly Dictionary _versionCache = []; + private readonly Dictionary _versionCache = []; private readonly Dictionary _agentCache = []; private AIProjectClient? _agentClient; @@ -99,7 +99,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj IDictionary? inputArguments, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - AgentVersion agentVersionResult = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false); + ProjectsAgentVersion agentVersionResult = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false); AIAgent agent = await this.GetAgentAsync(agentVersionResult, cancellationToken).ConfigureAwait(false); ChatOptions chatOptions = @@ -133,10 +133,10 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj } } - private async Task QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default) + private async Task QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default) { string agentKey = $"{agentName}:{agentVersion}"; - if (this._versionCache.TryGetValue(agentKey, out AgentVersion? targetAgent)) + if (this._versionCache.TryGetValue(agentKey, out ProjectsAgentVersion? targetAgent)) { return targetAgent; } @@ -145,8 +145,8 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj if (string.IsNullOrEmpty(agentVersion)) { - AgentRecord agentRecord = - await client.Agents.GetAgentAsync( + ProjectsAgentRecord agentRecord = + await client.AgentAdministrationClient.GetAgentAsync( agentName, cancellationToken).ConfigureAwait(false); @@ -155,7 +155,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj else { targetAgent = - await client.Agents.GetAgentVersionAsync( + await client.AgentAdministrationClient.GetAgentVersionAsync( agentName, agentVersion, cancellationToken).ConfigureAwait(false); @@ -166,7 +166,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj return targetAgent; } - private async Task GetAgentAsync(AgentVersion agentVersion, CancellationToken cancellationToken = default) + private async Task GetAgentAsync(ProjectsAgentVersion agentVersion, CancellationToken cancellationToken = default) { if (this._agentCache.TryGetValue(agentVersion.Id, out AIAgent? agent)) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml similarity index 80% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/CompatibilitySuppressions.xml rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml index 3454984fae..fbf1db84c7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml @@ -1,39 +1,39 @@ - + CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj similarity index 70% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj index 5bf9f6d29e..407593536e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj @@ -13,10 +13,16 @@ + + + + false + + - Microsoft Agent Framework Declarative Workflows Azure AI - Provides Microsoft Agent Framework support for declarative workflows for Azure AI Agents. + Microsoft Agent Framework Declarative Workflows Foundry + Provides Microsoft Agent Framework support for declarative workflows for Microsoft Foundry Agents. @@ -24,7 +30,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md index 4408f0febd..2a50b6045d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md @@ -4,9 +4,9 @@ Declarative Workflows is a no-code platform for orchestrating AI agents to accom It allows users to design, execute, and monitor workflows using simple declarative configurations—no coding required. By connecting multiple AI agents and services, it enables automation of sophisticated processes that traditionally require custom engineering. -We've provided a set of [Sample Workflows](../../../workflow-samples/) within the `agent-framework` repository. +We've provided a set of [Sample Workflows](../../../declarative-agents/workflow-samples/) within the `agent-framework` repository. -Please refer to the [README](../../../workflow-samples/README.md) for setup instructions to run the sample workflows in your environment. +Please refer to the [README](../../../declarative-agents/workflow-samples/README.md) for setup instructions to run the sample workflows in your environment. As part of our [Getting Started with Declarative Workflows](../../samples/03-workflows/Declarative/README.md), we've provided a console application that is able to execute any declarative workflow. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj index d738fedf40..765de06c7b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj @@ -29,7 +29,7 @@ - true + true diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 22ee1b48eb..9d7aa5b8c7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Specialized; @@ -154,6 +155,7 @@ public static partial class AgentWorkflowBuilder /// The must be capable of understanding those provided. If the agent /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. /// + [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) { Throw.IfNull(initialAgent); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs index 74ccd8edc3..f4e159a487 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs @@ -21,6 +21,15 @@ internal interface ICheckpointingHandle /// /// Restores the system state from the specified checkpoint asynchronously. /// + /// + /// This contract is used by live runtime restore paths. Implementations may re-emit pending + /// external request events as part of the restore once the active event stream is ready to + /// observe them. + /// + /// Initial resume paths that create a new event stream should restore state first and defer + /// any replay until after the subscriber is attached, rather than calling this contract + /// directly before the stream is ready. + /// /// The checkpoint information that identifies the state to restore. Cannot be null. /// A cancellation token that can be used to cancel the restore operation. /// A that represents the asynchronous restore operation. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Workflows/CompatibilitySuppressions.xml deleted file mode 100644 index f227141706..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/CompatibilitySuppressions.xml +++ /dev/null @@ -1,319 +0,0 @@ - - - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config`1 - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`1 - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`2 - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config`1 - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`1 - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`2 - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config`1 - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`1 - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`2 - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config`1 - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`1 - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`2 - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Config`1 - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`1 - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0001 - T:Microsoft.Agents.AI.Workflows.Configured`2 - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent) - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent) - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net472/Microsoft.Agents.AI.Workflows.dll - lib/net472/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent) - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent) - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent) - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1) - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll - true - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs index bda7e61a38..16cd61f6e1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs @@ -36,9 +36,10 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable this._eventStream.Start(); - // If there are already unprocessed messages (e.g., from a checkpoint restore that happened - // before this handle was created), signal the run loop to start processing them - if (stepRunner.HasUnprocessedMessages) + // If there are already unprocessed messages or unserviced requests (e.g., from a + // checkpoint restore that happened before this handle was created), signal the run + // loop to start processing them + if (stepRunner.HasUnprocessedMessages || stepRunner.HasUnservicedRequests) { this.SignalInputToRunLoop(); } @@ -192,13 +193,17 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable { streamingEventStream.ClearBufferedEvents(); } + else if (this._eventStream is LockstepRunEventStream lockstepEventStream) + { + lockstepEventStream.ClearBufferedEvents(); + } - // Restore the workflow state - this will republish unserviced requests as new events + // Restore the workflow state through the live runtime-restore path. + // This can re-emit pending requests into the already-active event stream. await this._checkpointingHandle.RestoreCheckpointAsync(checkpointInfo, cancellationToken).ConfigureAwait(false); - // After restore, signal the run loop to process any restored messages - // This is necessary because ClearBufferedEvents() doesn't signal, and the restored - // queued messages won't automatically wake up the run loop + // After restore, signal the run loop to process any restored messages. Initial resume + // paths handle this separately when they create the event stream after restoring state. this.SignalInputToRunLoop(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs index 8de0dbd5e2..ea53526604 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs @@ -27,6 +27,14 @@ internal interface ISuperStepRunner ConcurrentEventSink OutgoingEvents { get; } + /// + /// Re-emits s for any pending external requests. + /// Called by event streams after subscribing to so that + /// requests restored from a checkpoint are observable even when the restore happened + /// before the subscription was active. + /// + ValueTask RepublishPendingEventsAsync(CancellationToken cancellationToken = default); + ValueTask RunSuperStepAsync(CancellationToken cancellationToken); // This cannot be cancelled diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index 72e96efb10..cdd8cc7686 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -15,6 +15,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream { private readonly CancellationTokenSource _stopCancellation = new(); private readonly InputWaiter _inputWaiter = new(); + private ConcurrentQueue _eventSink = new(); private int _isDisposed; private readonly ISuperStepRunner _stepRunner; @@ -35,6 +36,8 @@ internal sealed class LockstepRunEventStream : IRunEventStream // doesn't leak into caller code via AsyncLocal. Activity? previousActivity = Activity.Current; + this._stepRunner.OutgoingEvents.EventRaised += this.OnWorkflowEventAsync; + this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity(); this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId) .SetTag(Tags.SessionId, this._stepRunner.SessionId); @@ -56,10 +59,6 @@ internal sealed class LockstepRunEventStream : IRunEventStream using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken); - ConcurrentQueue eventSink = []; - - this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync; - // Re-establish session as parent so the run activity nests correctly. Activity.Current = this._sessionActivity; @@ -73,7 +72,31 @@ internal sealed class LockstepRunEventStream : IRunEventStream runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); // Emit WorkflowStartedEvent to the event stream for consumers - eventSink.Enqueue(new WorkflowStartedEvent()); + this._eventSink.Enqueue(new WorkflowStartedEvent()); + + // Re-emit any pending external requests that were restored from a checkpoint + // before this subscription was active. For non-resume starts this is a no-op. + // This runs after WorkflowStartedEvent so consumers always see the started event first. + await this._stepRunner.RepublishPendingEventsAsync(linkedSource.Token).ConfigureAwait(false); + + // When resuming from a checkpoint with only pending requests (no queued messages), + // the inner processing loop won't execute, so we must drain events now. + // For normal starts this is a no-op since the inner loop handles the drain. + if (!this._stepRunner.HasUnprocessedMessages) + { + var (drainedEvents, shouldHalt) = this.DrainAndFilterEvents(); + foreach (WorkflowEvent raisedEvent in drainedEvents) + { + yield return raisedEvent; + } + + if (shouldHalt) + { + yield break; + } + + this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle; + } do { @@ -107,26 +130,19 @@ internal sealed class LockstepRunEventStream : IRunEventStream yield break; // Exit if cancellation is requested } - bool hadRequestHaltEvent = false; - foreach (WorkflowEvent raisedEvent in Interlocked.Exchange(ref eventSink, [])) + var (drainedEvents, shouldHalt) = this.DrainAndFilterEvents(); + + foreach (WorkflowEvent raisedEvent in drainedEvents) { if (linkedSource.Token.IsCancellationRequested) { yield break; // Exit if cancellation is requested } - // TODO: Do we actually want to interpret this as a termination request? - if (raisedEvent is RequestHaltEvent) - { - hadRequestHaltEvent = true; - } - else - { - yield return raisedEvent; - } + yield return raisedEvent; } - if (hadRequestHaltEvent || linkedSource.Token.IsCancellationRequested) + if (shouldHalt || linkedSource.Token.IsCancellationRequested) { // If we had a completion event, we are done. yield break; @@ -151,25 +167,23 @@ internal sealed class LockstepRunEventStream : IRunEventStream finally { this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle; - this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync; // Explicitly dispose the Activity so Activity.Stop fires deterministically, // regardless of how the async iterator enumerator is disposed. runActivity?.Dispose(); } - ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e) - { - eventSink.Enqueue(e); - return default; - } - // If we are Idle or Ended, we should break out of the loop // If we are PendingRequests and not blocking on pending requests, we should break out of the loop // If cancellation is requested, we should break out of the loop bool ShouldBreak() => this.RunStatus is RunStatus.Idle or RunStatus.Ended || - (this.RunStatus == RunStatus.PendingRequests && !blockOnPendingRequest) || - linkedSource.Token.IsCancellationRequested; + (this.RunStatus == RunStatus.PendingRequests && !blockOnPendingRequest) || + linkedSource.Token.IsCancellationRequested; + } + + internal void ClearBufferedEvents() + { + Interlocked.Exchange(ref this._eventSink, new ConcurrentQueue()); } /// @@ -192,6 +206,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream if (Interlocked.Exchange(ref this._isDisposed, 1) == 0) { this._stopCancellation.Cancel(); + this._stepRunner.OutgoingEvents.EventRaised -= this.OnWorkflowEventAsync; // Stop the session activity if (this._sessionActivity is not null) @@ -207,4 +222,32 @@ internal sealed class LockstepRunEventStream : IRunEventStream return default; } + + private ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e) + { + this._eventSink.Enqueue(e); + return default; + } + + // Atomically drains the event sink and separates workflow events from halt signals. + // Used by both the early-drain (resume with pending requests only) and + // the inner superstep drain to keep halt-detection logic in one place. + private (List Events, bool ShouldHalt) DrainAndFilterEvents() + { + List events = []; + bool shouldHalt = false; + foreach (WorkflowEvent e in Interlocked.Exchange(ref this._eventSink, new ConcurrentQueue())) + { + if (e is RequestHaltEvent) + { + shouldHalt = true; + } + else + { + events.Add(e); + } + } + + return (events, shouldHalt); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index 6278f3446b..4eb1290961 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -23,7 +23,8 @@ internal sealed class StreamingRunEventStream : IRunEventStream private readonly CancellationTokenSource _runLoopCancellation; private readonly bool _disableRunLoop; private Task? _runLoopTask; - private RunStatus _runStatus = RunStatus.NotStarted; + private volatile RunStatus _runStatus = RunStatus.NotStarted; + private int _completionEpoch; // Tracks which completion signal belongs to which consumer iteration public StreamingRunEventStream(ISuperStepRunner stepRunner, bool disableRunLoop = false) @@ -60,6 +61,10 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Subscribe to events - they will flow directly to the channel as they're raised this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync; + // Re-emit any pending external requests that were restored from a checkpoint + // before this subscription was active. For non-resume starts this is a no-op. + await this._stepRunner.RepublishPendingEventsAsync(linkedSource.Token).ConfigureAwait(false); + // Start the session-level activity that spans the entire run loop lifetime. // Individual run-stage activities are nested within this session activity. Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity(); @@ -123,7 +128,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Wait for next input from the consumer // Works for both Idle (no work) and PendingRequests (waiting for responses) - await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false); + await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false); // When signaled, resume running this._runStatus = RunStatus.Running; @@ -205,7 +210,10 @@ internal sealed class StreamingRunEventStream : IRunEventStream [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Get the current epoch - we'll only respond to completion signals from this epoch or later - int myEpoch = Volatile.Read(ref this._completionEpoch) + 1; + int currentEpoch = Volatile.Read(ref this._completionEpoch); + + bool expectingFreshWork = this._stepRunner.HasUnprocessedMessages || this._runStatus == RunStatus.Running; + int myEpoch = expectingFreshWork ? currentEpoch + 1 : currentEpoch; // Use custom async enumerable to avoid exceptions on cancellation. NonThrowingChannelReaderAsyncEnumerable eventStream = new(this._eventChannel.Reader); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs similarity index 95% rename from dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 8e5cee7ed5..4e9f201053 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; @@ -9,13 +10,21 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; +internal static class DiagnosticConstants +{ + public const string ExperimentalFeatureDiagnostic = "MAAIW001"; +} + /// [Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] +#pragma warning disable MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +#pragma warning restore MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. { } /// +[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) { } @@ -23,6 +32,7 @@ public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkfl /// /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. /// +[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs index 1eccb391fd..a2561437ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs @@ -50,10 +50,13 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen return runner.BeginStreamAsync(this.ExecutionMode, cancellationToken); } - internal ValueTask ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, CancellationToken cancellationToken) + internal ValueTask ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, CancellationToken cancellationToken = default) + => this.ResumeRunAsync(workflow, fromCheckpoint, knownValidInputTypes, republishPendingEvents: true, cancellationToken); + + internal ValueTask ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, bool republishPendingEvents, CancellationToken cancellationToken = default) { InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, this.CheckpointManager, fromCheckpoint.SessionId, this.EnableConcurrentRuns, knownValidInputTypes); - return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, cancellationToken); + return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, republishPendingEvents, cancellationToken); } /// @@ -104,6 +107,32 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen return new(runHandle); } + /// + /// Resumes a streaming workflow run from a checkpoint with control over whether + /// pending request events are republished through the event stream. + /// + /// The workflow to resume. + /// The checkpoint to resume from. + /// + /// When , any pending request events are republished through the event + /// stream after subscribing. When , the caller is responsible for + /// handling pending requests (e.g., already sends responses). + /// + /// Cancellation token. + internal async ValueTask ResumeStreamingInternalAsync( + Workflow workflow, + CheckpointInfo fromCheckpoint, + bool republishPendingEvents, + CancellationToken cancellationToken = default) + { + this.VerifyCheckpointingConfigured(); + + AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, fromCheckpoint, [], republishPendingEvents, cancellationToken) + .ConfigureAwait(false); + + return new(runHandle); + } + private async ValueTask BeginRunHandlingChatProtocolAsync(Workflow workflow, TInput input, string? sessionId = null, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index f93b09ddf3..0daa9bf285 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -71,6 +71,28 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle /// public string StartExecutorId { get; } + /// + /// Gating flag for deferred event republishing after checkpoint restore. + /// + /// + /// + /// Written with in + /// and consumed atomically with in + /// . The write does not need a full + /// memory barrier because it is sequenced before the constructor + /// by the in . The constructor is the + /// only code path that triggers consumption (via the event stream's subscribe and republish flow). + /// + /// + /// Note: also reads + /// in its constructor to signal the run loop, but that property reads from + /// 's request dictionary (restored during + /// ), not from this flag. The two are independent: + /// HasUnservicedRequests triggers the run loop; _needsRepublish triggers event emission. + /// + /// + private int _needsRepublish; + /// public WorkflowTelemetryContext TelemetryContext => this.Workflow.TelemetryContext; @@ -145,7 +167,10 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle return new(new AsyncRunHandle(this, this, mode)); } - public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default) + public ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default) + => this.ResumeStreamAsync(mode, fromCheckpoint, republishPendingEvents: true, cancellationToken); + + public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, bool republishPendingEvents, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); Throw.IfNull(fromCheckpoint); @@ -154,7 +179,18 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle throw new InvalidOperationException("This runner was not configured with a CheckpointManager, so it cannot restore checkpoints."); } - await this.RestoreCheckpointAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false); + // Restore checkpoint state without republishing pending request events. + // The event stream will republish them after subscribing so that events + // are never lost to an absent subscriber. + await this.RestoreCheckpointCoreAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false); + + if (republishPendingEvents) + { + // Signal the event stream to republish pending requests after subscribing. + // This is consumed atomically by RepublishPendingEventsAsync. + Volatile.Write(ref this._needsRepublish, 1); + } + return new AsyncRunHandle(this, this, mode); } @@ -163,6 +199,16 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId) => this.RunContext.TryGetResponsePortExecutorId(portId, out executorId); + ValueTask ISuperStepRunner.RepublishPendingEventsAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref this._needsRepublish, 0) != 0) + { + return this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken); + } + + return default; + } + public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled; public IReadOnlyList Checkpoints => this._checkpoints; @@ -310,7 +356,31 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle this._checkpoints.Add(this._lastCheckpointInfo); } + /// + /// Restores checkpoint state and re-emits any pending external request events. + /// + /// + /// This is the implementation used for runtime restores + /// where the event stream subscription is already active. For initial resumes, + /// calls + /// directly and defers republishing to the event stream. + /// public async ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default) + { + await this.RestoreCheckpointCoreAsync(checkpointInfo, cancellationToken).ConfigureAwait(false); + + // Republish pending request events. This is safe for runtime restores where + // the event stream is already subscribed. For initial resumes the event stream + // handles republishing itself, so ResumeStreamAsync calls RestoreCheckpointCoreAsync directly. + await this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Restores checkpoint state (queued messages, executor state, edge state, etc.) + /// without republishing pending request events. The caller is responsible for + /// ensuring events are republished after an event subscriber is attached. + /// + private async ValueTask RestoreCheckpointCoreAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); Throw.IfNull(checkpointInfo); @@ -335,11 +405,9 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle await this.RunContext.ImportStateAsync(checkpoint).ConfigureAwait(false); Task executorNotifyTask = this.RunContext.NotifyCheckpointLoadedAsync(cancellationToken); - ValueTask republishRequestsTask = this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken); await this.EdgeMap.ImportStateAsync(checkpoint).ConfigureAwait(false); await Task.WhenAll(executorNotifyTask, - republishRequestsTask.AsTask(), restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false); this._lastCheckpointInfo = checkpointInfo; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj index c103ead32d..032314c657 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj @@ -1,13 +1,14 @@ - + - true + true $(NoWarn);MEAI001 true true + true true diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 98205a40c6..e885b894fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; using System.Threading; @@ -31,6 +32,7 @@ internal sealed class HandoffAgentExecutorOptions public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; } +[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] internal sealed class HandoffMessagesFilter { private readonly HandoffToolCallFilteringBehavior _filteringBehavior; @@ -40,6 +42,7 @@ internal sealed class HandoffMessagesFilter this._filteringBehavior = filteringBehavior; } + [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] internal static bool IsHandoffFunctionName(string name) { return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); @@ -164,6 +167,7 @@ internal sealed class HandoffMessagesFilter } /// Executor used to represent an agent in a handoffs workflow, responding to events. +[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] internal sealed class HandoffAgentExecutor( AIAgent agent, HandoffAgentExecutorOptions options) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs index b35d682f2c..52dae66b88 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs @@ -14,6 +14,7 @@ internal sealed class RequestPortOptions; internal sealed class RequestInfoExecutor : Executor { + private const string WrappedRequestsStateKey = nameof(WrappedRequestsStateKey); private readonly Dictionary _wrappedRequests = []; private RequestPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } @@ -124,22 +125,46 @@ internal sealed class RequestInfoExecutor : Executor return null; } - if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) - { - await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false); - } - else - { - await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); - } - if (!message.Data.IsType(this.Port.Response, out object? data)) { throw this.Port.CreateExceptionForType(message); } - await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false); + if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) + { + await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false); + this._wrappedRequests.Remove(message.RequestId); + } + else + { + await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false); + } return message; } + + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(WrappedRequestsStateKey, + new Dictionary(this._wrappedRequests, StringComparer.Ordinal), + cancellationToken: cancellationToken).ConfigureAwait(false); + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + + this._wrappedRequests.Clear(); + + Dictionary wrappedRequests = + await context.ReadStateAsync>(WrappedRequestsStateKey, cancellationToken: cancellationToken) + .ConfigureAwait(false) ?? []; + + foreach (KeyValuePair wrappedRequest in wrappedRequests) + { + this._wrappedRequests[wrappedRequest.Key] = wrappedRequest.Value; + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs index 107dc3fd7a..58e3a9e523 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -23,6 +24,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable private InProcessRunner? _activeRunner; private InMemoryCheckpointManager? _checkpointManager; private readonly ExecutorOptions _options; + private readonly ConcurrentDictionary _pendingResponsePorts = new(StringComparer.Ordinal); private ISuperStepJoinContext? _joinContext; private string? _joinId; @@ -163,6 +165,11 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable private ExternalResponse? CheckAndUnqualifyResponse([DisallowNull] ExternalResponse response) { + if (this._pendingResponsePorts.TryRemove(response.RequestId, out RequestPortInfo? originalPort)) + { + return response with { PortInfo = originalPort }; + } + if (!Throw.IfNull(response).PortInfo.PortId.StartsWith($"{this.Id}.", StringComparison.Ordinal)) { return null; @@ -193,6 +200,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable break; case RequestInfoEvent requestInfoEvt: ExternalRequest request = requestInfoEvt.Request; + this._pendingResponsePorts[request.RequestId] = request.PortInfo; resultTask = this._joinContext?.SendMessageAsync(this.Id, this.QualifyRequestPortId(request)).AsTask() ?? Task.CompletedTask; break; case WorkflowErrorEvent errorEvent: @@ -246,9 +254,13 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable } private const string CheckpointManagerStateKey = nameof(CheckpointManager); + private const string PendingResponsePortsStateKey = nameof(PendingResponsePortsStateKey); protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { await context.QueueStateUpdateAsync(CheckpointManagerStateKey, this._checkpointManager, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(PendingResponsePortsStateKey, + new Dictionary(this._pendingResponsePorts, StringComparer.Ordinal), + cancellationToken: cancellationToken).ConfigureAwait(false); await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); } @@ -269,6 +281,15 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable await this.ResetAsync().ConfigureAwait(false); } + this._pendingResponsePorts.Clear(); + Dictionary pendingResponsePorts = + await context.ReadStateAsync>(PendingResponsePortsStateKey, cancellationToken: cancellationToken) + .ConfigureAwait(false) ?? []; + foreach (KeyValuePair pendingResponsePort in pendingResponsePorts) + { + this._pendingResponsePorts[pendingResponsePort.Key] = pendingResponsePort.Value; + } + await this.EnsureRunSendMessageAsync(resume: true, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -280,6 +301,8 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable this._run = null; } + this._pendingResponsePorts.Clear(); + if (this._activeRunner != null) { this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 715c232e7d..c1f81f0ecf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -19,7 +19,14 @@ namespace Microsoft.Agents.AI.Workflows; internal sealed class WorkflowSession : AgentSession { private readonly Workflow _workflow; - private readonly IWorkflowExecutionEnvironment _executionEnvironment; + + /// + /// The execution environment for this session. Concrete type is required because + /// uses the internal + /// API. + /// + private readonly InProcessExecutionEnvironment _inProcEnvironment; + private readonly bool _includeExceptionDetails; private readonly bool _includeWorkflowOutputsInResponse; @@ -63,17 +70,22 @@ internal sealed class WorkflowSession : AgentSession public WorkflowSession(Workflow workflow, string sessionId, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) { this._workflow = Throw.IfNull(workflow); - this._executionEnvironment = Throw.IfNull(executionEnvironment); this._includeExceptionDetails = includeExceptionDetails; this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse; - if (VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv)) + IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment); + if (VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv)) { // We have an InProcessExecutionEnvironment which is not configured for checkpointing. Ensure it has an externalizable checkpoint manager, // since we are responsible for maintaining the state. - this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); + env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); } + this._inProcEnvironment = env as InProcessExecutionEnvironment + ?? throw new InvalidOperationException( + $"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " + + $"but received {env.GetType().Name}."); + this.SessionId = Throw.IfNullOrEmpty(sessionId); this.ChatHistoryProvider = new WorkflowChatHistoryProvider(); } @@ -86,24 +98,30 @@ internal sealed class WorkflowSession : AgentSession public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, JsonSerializerOptions? jsonSerializerOptions = null) { this._workflow = Throw.IfNull(workflow); - this._executionEnvironment = Throw.IfNull(executionEnvironment); this._includeExceptionDetails = includeExceptionDetails; this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse; + IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment); + JsonMarshaller marshaller = new(jsonSerializerOptions); SessionState sessionState = marshaller.Marshal(serializedSession); this._inMemoryCheckpointManager = sessionState.CheckpointManager; if (this._inMemoryCheckpointManager != null && - VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv)) + VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv)) { - this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); + env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); } else if (this._inMemoryCheckpointManager != null) { throw new ArgumentException("The session was saved with an externalized checkpoint manager, but the incoming execution environment does not support it.", nameof(executionEnvironment)); } + this._inProcEnvironment = env as InProcessExecutionEnvironment + ?? throw new InvalidOperationException( + $"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " + + $"but received {env.GetType().Name}."); + this.SessionId = sessionState.SessionId; this.ChatHistoryProvider = new WorkflowChatHistoryProvider(); @@ -160,10 +178,15 @@ internal sealed class WorkflowSession : AgentSession // and does not need to be checked again here. if (this.LastCheckpoint is not null) { + // Use the internal resume path that suppresses pending request republishing. + // WorkflowSession handles pending requests itself by converting matching responses + // via SendMessagesWithResponseConversionAsync, so event-stream republishing would + // cause unwanted duplicate events visible to the consumer. StreamingRun run = - await this._executionEnvironment - .ResumeStreamingAsync(this._workflow, + await this._inProcEnvironment + .ResumeStreamingInternalAsync(this._workflow, this.LastCheckpoint, + republishPendingEvents: false, cancellationToken) .ConfigureAwait(false); @@ -172,7 +195,7 @@ internal sealed class WorkflowSession : AgentSession return new ResumeRunResult(run, dispatchInfo); } - StreamingRun newRun = await this._executionEnvironment + StreamingRun newRun = await this._inProcEnvironment .RunStreamingAsync(this._workflow, messages, this.SessionId, diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 713361d9be..44a136da3e 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -139,8 +139,8 @@ public sealed partial class ChatClientAgent : AIAgent this._logger = (loggerFactory ?? chatClient.GetService() ?? NullLoggerFactory.Instance).CreateLogger(); - // Warn if using a custom chat client stack with simulated service stored persistence but no ServiceStoredSimulatingChatClient. - this.WarnOnMissingServiceStoredSimulatingClient(); + // Warn if using a custom chat client stack with simulated service stored persistence but no PerServiceCallChatHistoryPersistingChatClient. + this.WarnOnMissingPerServiceCallChatHistoryPersistingChatClient(); } /// @@ -454,7 +454,7 @@ public sealed partial class ChatClientAgent : AIAgent /// Notifies the and all of successfully completed messages. /// /// - /// This method is also called by to persist messages per-service-call. + /// This method is also called by to persist messages per-service-call. /// internal async Task NotifyProvidersOfNewMessagesAsync( ChatClientAgentSession session, @@ -486,7 +486,7 @@ public sealed partial class ChatClientAgent : AIAgent /// Notifies the and all of a failure during a service call. /// /// - /// This method is also called by to report failures per-service-call. + /// This method is also called by to report failures per-service-call. /// internal async Task NotifyProvidersOfFailureAsync( ChatClientAgentSession session, @@ -701,7 +701,7 @@ public sealed partial class ChatClientAgent : AIAgent throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token."); } - if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.SimulatesServiceStoredChatHistory && this._logger.IsEnabled(LogLevel.Warning)) + if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.RequiresPerServiceCallChatHistoryPersistence && this._logger.IsEnabled(LogLevel.Warning)) { var warningAgentName = this.GetLoggingAgentName(); this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName); @@ -740,10 +740,10 @@ public sealed partial class ChatClientAgent : AIAgent IEnumerable inputMessagesForChatClient = inputMessages; // Populate the session messages only if we are not continuing an existing response as it's not allowed. - // When SimulateServiceStoredChatHistory is active, the ServiceStoredSimulatingChatClient + // When RequirePerServiceCallChatHistoryPersistence is active, the PerServiceCallChatHistoryPersistingChatClient // owns the chat history lifecycle — it loads history before each service call. The agent // must not load history itself, as that would result in duplicate messages. - if (chatOptions?.ContinuationToken is null && !this.SimulatesServiceStoredChatHistory) + if (chatOptions?.ContinuationToken is null && !this.RequiresPerServiceCallChatHistoryPersistence) { // Add any existing messages from the session to the messages to be sent to the chat client. // The ChatHistoryProvider returns the merged result (history + input messages). @@ -837,14 +837,14 @@ public sealed partial class ChatClientAgent : AIAgent /// Updates the session conversation ID at the end of an agent run. /// /// - /// When a handles per-service-call + /// When a handles per-service-call /// conversation ID updates, this end-of-run update is skipped. When the decorator is /// absent, the update is performed here. When is /// (continuation token scenarios), the update is always performed. /// private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false) { - if (!forceUpdate && this.SimulatesServiceStoredChatHistory) + if (!forceUpdate && this.RequiresPerServiceCallChatHistoryPersistence) { return; } @@ -856,7 +856,7 @@ public sealed partial class ChatClientAgent : AIAgent /// Notifies providers of successfully completed messages at the end of an agent run. /// /// - /// When a handles per-service-call + /// When a handles per-service-call /// notification, this end-of-run notification is skipped. When no decorator is present, /// all messages are persisted. /// When is (continuation token or @@ -871,7 +871,7 @@ public sealed partial class ChatClientAgent : AIAgent CancellationToken cancellationToken, bool forceNotify = false) { - if (!forceNotify && this.SimulatesServiceStoredChatHistory) + if (!forceNotify && this.RequiresPerServiceCallChatHistoryPersistence) { return Task.CompletedTask; } @@ -883,7 +883,7 @@ public sealed partial class ChatClientAgent : AIAgent /// Notifies providers of a failure at the end of an agent run. /// /// - /// When a handles per-service-call + /// When a handles per-service-call /// notification (including failure), this end-of-run notification is skipped to avoid /// duplicate notification. In all other cases, failure is reported at the end of the run. /// @@ -894,7 +894,7 @@ public sealed partial class ChatClientAgent : AIAgent ChatOptions? chatOptions, CancellationToken cancellationToken) { - if (this.SimulatesServiceStoredChatHistory) + if (this.RequiresPerServiceCallChatHistoryPersistence) { return Task.CompletedTask; } @@ -905,14 +905,14 @@ public sealed partial class ChatClientAgent : AIAgent /// /// Gets a value indicating whether the agent is configured to simulate service-stored chat history. /// When , end-of-run persistence and history loading are skipped because a - /// per-service-call decorator (such as or a + /// per-service-call decorator (such as or a /// user-supplied equivalent) is expected to handle the history lifecycle. /// - private bool SimulatesServiceStoredChatHistory + private bool RequiresPerServiceCallChatHistoryPersistence { get { - return this._agentOptions?.SimulateServiceStoredChatHistory is true; + return this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is true; } } @@ -923,7 +923,7 @@ public sealed partial class ChatClientAgent : AIAgent /// The base class sets with the raw session parameter /// (which may be null) and restores it after each yield in streaming scenarios. After /// resolves or creates a session, we update the - /// context so the decorator always has a valid session. + /// context so the decorator always has a valid session. /// The original agent from the context is preserved to maintain the top-of-stack agent in /// decorated agent scenarios. /// @@ -939,19 +939,19 @@ public sealed partial class ChatClientAgent : AIAgent /// /// Checks for potential misconfiguration when using a custom chat client stack and logs warnings. /// - private void WarnOnMissingServiceStoredSimulatingClient() + private void WarnOnMissingPerServiceCallChatHistoryPersistingChatClient() { if (this._agentOptions?.UseProvidedChatClientAsIs is not true) { return; } - if (this._agentOptions?.SimulateServiceStoredChatHistory is not true) + if (this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is not true) { return; } - var persistingClient = this.ChatClient.GetService(); + var persistingClient = this.ChatClient.GetService(); if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning)) { var loggingAgentName = this.GetLoggingAgentName(); @@ -998,7 +998,7 @@ public sealed partial class ChatClientAgent : AIAgent /// /// /// This method is used by both the agent (during ) and by - /// to load history before each service call. + /// to load history before each service call. /// internal async Task> LoadChatHistoryAsync( ChatClientAgentSession session, diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs index 5899ef5cb2..dde1d97ba4 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs @@ -72,12 +72,12 @@ internal static partial class ChatClientAgentLogMessages /// /// Logs a warning when is - /// and is , - /// but no is found in the custom chat client stack. + /// and is , + /// but no is found in the custom chat client stack. /// [LoggerMessage( Level = LogLevel.Warning, - Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no ServiceStoredSimulatingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a ServiceStoredSimulatingChatClient to the pipeline using the UseServiceStoredChatHistorySimulation extension method if you have not added your own persistence mechanism.")] + Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no PerServiceCallChatHistoryPersistingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a PerServiceCallChatHistoryPersistingChatClient to the pipeline using the UsePerServiceCallChatHistoryPersistence extension method if you have not added your own persistence mechanism.")] public static partial void LogAgentChatClientMissingPersistingClient( this ILogger logger, string agentId, @@ -92,7 +92,7 @@ internal static partial class ChatClientAgentLogMessages /// [LoggerMessage( Level = LogLevel.Warning, - Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")] + Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")] public static partial void LogAgentChatClientBackgroundResponseFallback( this ILogger logger, string agentId, diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index e3d4326ae6..e7340cec19 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -92,38 +92,56 @@ public sealed class ChatClientAgentOptions public bool ThrowOnChatHistoryProviderConflict { get; set; } = true; /// - /// Gets or sets a value indicating whether the should simulate - /// service-stored chat history behavior using its configured . + /// Gets or sets a value indicating whether the should persist + /// chat history after each individual service call within the + /// loop, rather than at the end of the full agent run. /// /// /// - /// When set to , a decorator is - /// injected between the and the leaf - /// in the chat client pipeline. This decorator takes full ownership of the chat history lifecycle: - /// it loads history from the before each service call and persists - /// new messages after each service call. It also returns a sentinel - /// on the response, causing the to treat the conversation - /// as service-managed — clearing accumulated history and not injecting duplicate - /// during approval-response processing. - /// - /// - /// This mode aligns the behavior of framework-managed chat history with service-stored chat history, - /// ensuring consistency in how messages are stored and loaded, including during function calling loops - /// and tool-call termination scenarios. + /// When set to , a + /// decorator becomes active in the chat client pipeline. It handles two complementary scenarios: /// + /// + /// + /// Framework-managed chat history + /// + /// The decorator loads history from the before each service call + /// and persists new request and response messages after each call. It returns a sentinel + /// on the response, causing the + /// to treat the conversation as service-managed — clearing + /// accumulated history between iterations and not injecting duplicate + /// during approval-response processing. + /// + /// + /// + /// AI Service-stored chat history + /// + /// When the service manages its own chat history (returning a real ), + /// the decorator updates after each service call so + /// that intermediate ConversationId changes are captured immediately. For some services (e.g., the + /// Conversations API with the Responses API), there is only one thread with one ID, so every service + /// call updates it anyway and updating the has little effect + /// since it's the same ID. For other services (e.g., Responses API with Response IDs), a new ID is generated + /// with each service call, so updating the ensures that the + /// latest ID is always captured, even mid-run. + /// Enabling this option ensures consistent per-service-call behavior across all service types. + /// + /// + /// /// /// When set to (the default), the handles - /// chat history persistence at the end of the full agent run via the - /// pipeline. + /// chat history persistence at the end of the full agent run via the if using + /// framework-managed chat history. For AI service-stored chat history, the + /// updates happen only at the end of the run. /// /// /// When setting the setting to and - /// to , ensure that your custom chat client stack includes a - /// to enable per-service-call persistence. - /// If no is provided, and you are not storing chat history via other means, + /// to , ensure that your custom chat client stack includes a + /// to enable per-service-call persistence. + /// If no is provided, and you are not storing chat history via other means, /// no chat history may be stored. - /// When using a custom chat client stack, you can add a - /// manually via the + /// When using a custom chat client stack, you can add a + /// manually via the /// extension method. /// /// @@ -131,7 +149,7 @@ public sealed class ChatClientAgentOptions /// Default is . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] - public bool SimulateServiceStoredChatHistory { get; set; } + public bool RequirePerServiceCallChatHistoryPersistence { get; set; } /// /// Creates a new instance of with the same values as this instance. @@ -149,6 +167,6 @@ public sealed class ChatClientAgentOptions ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict, WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict, ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict, - SimulateServiceStoredChatHistory = this.SimulateServiceStoredChatHistory, + RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence, }; } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs index 68e1307eeb..5cf87aa950 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs @@ -86,21 +86,21 @@ public static class ChatClientBuilderExtensions services: services); /// - /// Adds a to the chat client pipeline. + /// Adds a to the chat client pipeline. /// /// /// /// This decorator should be positioned between the and the leaf - /// in the pipeline. It simulates service-stored chat history behavior by - /// loading history before each service call, persisting after each call, and returning a sentinel - /// on the response. + /// in the pipeline. It persists chat history after each individual service call + /// and updates the session per call for both framework-managed + /// and service-stored chat history scenarios. /// /// /// This extension method is intended for use with custom chat client stacks when /// is . /// When is (the default), - /// the automatically injects this decorator when - /// is . + /// the automatically includes this decorator in the pipeline and activates it when + /// is . /// /// /// This decorator only works within the context of a running and will throw an @@ -110,8 +110,8 @@ public static class ChatClientBuilderExtensions /// The to add the decorator to. /// The for chaining. [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] - public static ChatClientBuilder UseServiceStoredChatHistorySimulation(this ChatClientBuilder builder) + public static ChatClientBuilder UsePerServiceCallChatHistoryPersistence(this ChatClientBuilder builder) { - return builder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient)); + return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient)); } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index 5251314766..b2c48ec572 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -63,16 +63,16 @@ public static class ChatClientExtensions }); } - // ServiceStoredSimulatingChatClient is only injected when SimulateServiceStoredChatHistory is enabled. + // PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled. // It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client. // ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost. // By adding our decorator second, the resulting pipeline is: - // FunctionInvokingChatClient → ServiceStoredSimulatingChatClient → leaf IChatClient + // FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient // This allows the decorator to simulate service-stored chat history by loading history before // each service call, persisting after each call, and returning a sentinel ConversationId. - if (options?.SimulateServiceStoredChatHistory is true) + if (options?.RequirePerServiceCallChatHistoryPersistence is true) { - chatBuilder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient)); + chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient)); } var agentChatClient = chatBuilder.Build(services); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ServiceStoredSimulatingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs similarity index 84% rename from dotnet/src/Microsoft.Agents.AI/ChatClient/ServiceStoredSimulatingChatClient.cs rename to dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs index f7dafa303d..200baf7b94 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ServiceStoredSimulatingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs @@ -11,23 +11,39 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; /// -/// A delegating chat client that simulates service-stored chat history behavior using -/// framework-managed instances. +/// A delegating chat client that persists chat history and updates session state after each +/// individual service call within the loop. /// /// /// /// This decorator is intended to operate between the and the leaf -/// in a pipeline. +/// in a pipeline. It is activated when +/// is . /// /// -/// Before each service call, it loads chat history from the agent's -/// and prepends it to the request messages. After each successful service call, it persists -/// new request and response messages to the provider. It also returns a sentinel -/// on the response so that the -/// treats the conversation as service-managed — -/// clearing accumulated history between iterations and not injecting duplicate -/// during approval-response processing. +/// When active, it handles two complementary scenarios: /// +/// +/// +/// Framework-managed chat history +/// +/// Before each service call, the decorator loads history from the agent's +/// and prepends it to the request messages. After each successful call, it persists new messages to +/// the provider and returns a sentinel so that +/// treats the conversation as service-managed — clearing +/// accumulated history between iterations and not injecting duplicate +/// during approval-response processing. +/// +/// +/// +/// Service-stored chat history +/// +/// When the underlying service manages its own chat history (real ), +/// the decorator updates after each service call so +/// that intermediate ConversationId changes are captured immediately rather than only at the end of the run. +/// +/// +/// /// /// This chat client must be used within the context of a running . It retrieves the /// current agent and session from , which is set automatically when an agent's @@ -38,7 +54,7 @@ namespace Microsoft.Agents.AI; /// available or if the agent is not a . /// /// -internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient +internal sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient { /// /// A sentinel value returned on to signal @@ -59,10 +75,10 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient internal const string LocalHistoryConversationId = "_agent_local_chat_history"; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The underlying chat client that will handle the core operations. - public ServiceStoredSimulatingChatClient(IChatClient innerClient) + public PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient) : base(innerClient) { } @@ -237,18 +253,18 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient { var runContext = AIAgent.CurrentRunContext ?? throw new InvalidOperationException( - $"{nameof(ServiceStoredSimulatingChatClient)} can only be used within the context of a running AIAgent. " + + $"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " + "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call."); var chatClientAgent = runContext.Agent.GetService() ?? throw new InvalidOperationException( - $"{nameof(ServiceStoredSimulatingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " + + $"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " + $"The current agent is of type '{runContext.Agent.GetType().Name}'."); if (runContext.Session is not ChatClientAgentSession chatClientAgentSession) { throw new InvalidOperationException( - $"{nameof(ServiceStoredSimulatingChatClient)} requires a {nameof(ChatClientAgentSession)}. " + + $"{nameof(PerServiceCallChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " + $"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'."); } diff --git a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml index 941346fbf1..8b7fd65d4c 100644 --- a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml @@ -2,71 +2,176 @@ - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProvider + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) lib/net10.0/Microsoft.Agents.AI.dll lib/net10.0/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory) lib/net10.0/Microsoft.Agents.AI.dll lib/net10.0/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProvider + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) lib/net472/Microsoft.Agents.AI.dll lib/net472/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory) lib/net472/Microsoft.Agents.AI.dll lib/net472/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProvider + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) lib/net8.0/Microsoft.Agents.AI.dll lib/net8.0/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory) lib/net8.0/Microsoft.Agents.AI.dll lib/net8.0/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProvider + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) lib/net9.0/Microsoft.Agents.AI.dll lib/net9.0/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory) lib/net9.0/Microsoft.Agents.AI.dll lib/net9.0/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProvider + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) lib/netstandard2.0/Microsoft.Agents.AI.dll lib/netstandard2.0/Microsoft.Agents.AI.dll true - CP0001 - T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions + CP0002 + M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(System.Collections.Generic.IEnumerable{Microsoft.Agents.AI.AgentInlineSkill},Microsoft.Agents.AI.AgentSkillsProviderOptions,Microsoft.Extensions.Logging.ILoggerFactory) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) lib/netstandard2.0/Microsoft.Agents.AI.dll lib/netstandard2.0/Microsoft.Agents.AI.dll true diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 70da404a61..10e92850d5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -1,7 +1,7 @@  - true + true $(NoWarn);MEAI001;MAAI001 diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs index ad647d2eb0..1ac44bfac8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -36,6 +37,11 @@ public abstract class AgentSkillScript /// public string? Description { get; } + /// + /// Gets the JSON schema describing the parameters accepted by this script, or if not available. + /// + public virtual JsonElement? ParametersSchema => null; + /// /// Runs the script with the given arguments. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index 70d7939227..b5598e19d3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -117,26 +117,24 @@ public sealed partial class AgentSkillsProvider : AIContextProvider } /// - /// Initializes a new instance of the class - /// with one or more inline (code-defined) skills. + /// Initializes a new instance of the class. /// Duplicate skill names are automatically deduplicated (first occurrence wins). /// - /// The inline skills to include. - public AgentSkillsProvider(params AgentInlineSkill[] skills) - : this(skills as IEnumerable) + /// The skills to include. + public AgentSkillsProvider(params AgentSkill[] skills) + : this(skills as IEnumerable) { } /// - /// Initializes a new instance of the class - /// with inline (code-defined) skills. + /// Initializes a new instance of the class. /// Duplicate skill names are automatically deduplicated (first occurrence wins). /// - /// The inline skills to include. + /// The skills to include. /// Optional provider configuration. /// Optional logger factory. public AgentSkillsProvider( - IEnumerable skills, + IEnumerable skills, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) : this( diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 8e52cc522e..0da54d0426 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -11,15 +11,31 @@ namespace Microsoft.Agents.AI; /// /// Fluent builder for constructing an backed by a composite source. +/// Intended for advanced scenarios where the simple constructors are insufficient. /// /// /// -/// Use this builder to combine multiple skill sources into a single provider: +/// For simple, single-source scenarios, prefer the constructors directly +/// (e.g., passing a skill directory path or a set of skills). Use this builder when you need one or more +/// of the following advanced capabilities: +/// +/// +/// Mixed skill types — combine file-based, code-defined (), +/// and class-based () skills in a single provider. +/// Multiple file script runners — use different script runners for different +/// file skill directories via per-source scriptRunner parameters on +/// / . +/// Skill filtering — include or exclude skills using a predicate +/// via . +/// +/// +/// Example — combining file-based and code-defined skills: /// /// /// var provider = new AgentSkillsProviderBuilder() /// .UseFileSkills("/path/to/skills") /// .UseSkills(myInlineSkill1, myInlineSkill2) +/// .UseFileScriptRunner(SubprocessScriptRunner.RunAsync) /// .Build(); /// /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs new file mode 100644 index 0000000000..47f12d5ea5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for defining skills as C# classes that bundle all components together. +/// +/// +/// +/// Inherit from this class to create a self-contained skill definition. Override the abstract +/// properties to provide name, description, and instructions. Use , +/// , and to define +/// inline resources and scripts. +/// +/// +/// +/// +/// public class PdfFormatterSkill : AgentClassSkill +/// { +/// private IReadOnlyList<AgentSkillResource>? _resources; +/// private IReadOnlyList<AgentSkillScript>? _scripts; +/// +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF."); +/// protected override string Instructions => "Use this skill to format documents..."; +/// +/// public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??= +/// [ +/// CreateResource("template", "Use this template..."), +/// ]; +/// +/// public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??= +/// [ +/// CreateScript("format-pdf", FormatPdf), +/// ]; +/// +/// private static string FormatPdf(string content) => content; +/// } +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentClassSkill : AgentSkill +{ + private string? _content; + + /// + /// Gets the raw instructions text for this skill. + /// + protected abstract string Instructions { get; } + + /// + /// + /// Returns a synthesized XML document containing name, description, instructions, resources, and scripts. + /// The result is cached after the first access. Override to provide custom content. + /// + public override string Content => this._content ??= AgentInlineSkillContentBuilder.Build( + this.Frontmatter.Name, + this.Frontmatter.Description, + this.Instructions, + this.Resources, + this.Scripts); + + /// + /// Creates a skill resource backed by a static value. + /// + /// The resource name. + /// The static resource value. + /// An optional description of the resource. + /// A new instance. + protected static AgentSkillResource CreateResource(string name, object value, string? description = null) + => new AgentInlineSkillResource(name, value, description); + + /// + /// Creates a skill resource backed by a delegate that produces a dynamic value. + /// + /// The resource name. + /// A method that produces the resource value when requested. + /// An optional description of the resource. + /// A new instance. + protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null) + => new AgentInlineSkillResource(name, method, description); + + /// + /// Creates a skill script backed by a delegate. + /// + /// The script name. + /// A method to execute when the script is invoked. + /// An optional description of the script. + /// A new instance. + protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null) + => new AgentInlineSkillScript(name, method, description); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs index d326a47a59..f24d41c362 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Text; -using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -27,8 +25,8 @@ namespace Microsoft.Agents.AI; public sealed class AgentInlineSkill : AgentSkill { private readonly string _instructions; - private List? _resources; - private List? _scripts; + private List? _resources; + private List? _scripts; private string? _cachedContent; /// @@ -77,7 +75,7 @@ public sealed class AgentInlineSkill : AgentSkill public override AgentSkillFrontmatter Frontmatter { get; } /// - public override string Content => this._cachedContent ??= this.BuildContent(); + public override string Content => this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts); /// public override IReadOnlyList? Resources => this._resources; @@ -125,91 +123,4 @@ public sealed class AgentInlineSkill : AgentSkill (this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description)); return this; } - - private string BuildContent() - { - var sb = new StringBuilder(); - - sb.Append($"{EscapeXmlString(this.Frontmatter.Name)}\n") - .Append($"{EscapeXmlString(this.Frontmatter.Description)}\n\n") - .Append("\n") - .Append(EscapeXmlString(this._instructions)) - .Append("\n"); - - if (this.Resources is { Count: > 0 }) - { - sb.Append("\n\n\n"); - foreach (var resource in this.Resources) - { - if (resource.Description is not null) - { - sb.Append($" \n"); - } - else - { - sb.Append($" \n"); - } - } - - sb.Append(""); - } - - if (this.Scripts is { Count: > 0 }) - { - sb.Append("\n\n\n"); - foreach (var script in this.Scripts) - { - JsonElement? parametersSchema = ((AgentInlineSkillScript)script).ParametersSchema; - - if (script.Description is null && parametersSchema is null) - { - sb.Append($" \n"); - } - } - - sb.Append(""); - } - - return sb.ToString(); - } - - /// - /// Escapes XML special characters: always escapes &, <, >, - /// ", and '. When is , - /// quotes are left unescaped to preserve readability of embedded content such as JSON. - /// - /// The string to escape. - /// - /// When , leaves " and ' unescaped for use in XML element content (e.g., JSON). - /// When (default), escapes all XML special characters including quotes. - /// - private static string EscapeXmlString(string value, bool preserveQuotes = false) - { - var result = value - .Replace("&", "&") - .Replace("<", "<") - .Replace(">", ">"); - - if (!preserveQuotes) - { - result = result - .Replace("\"", """) - .Replace("'", "'"); - } - - return result; - } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs new file mode 100644 index 0000000000..f90d67dd1d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Internal helper that builds XML-structured content strings for code-defined and class-based skills. +/// +internal static class AgentInlineSkillContentBuilder +{ + /// + /// Builds the complete skill content containing name, description, instructions, resources, and scripts. + /// + /// The skill name. + /// The skill description. + /// The raw instructions text. + /// Optional resources associated with the skill. + /// Optional scripts associated with the skill. + /// An XML-structured content string. + public static string Build( + string name, + string description, + string instructions, + IReadOnlyList? resources, + IReadOnlyList? scripts) + { + _ = Throw.IfNullOrWhitespace(name); + _ = Throw.IfNullOrWhitespace(description); + _ = Throw.IfNullOrWhitespace(instructions); + + var sb = new StringBuilder(); + + sb.Append($"{EscapeXmlString(name)}\n") + .Append($"{EscapeXmlString(description)}\n\n") + .Append("\n") + .Append(EscapeXmlString(instructions)) + .Append("\n"); + + if (resources is { Count: > 0 }) + { + sb.Append("\n\n\n"); + foreach (var resource in resources) + { + if (resource.Description is not null) + { + sb.Append($" \n"); + } + else + { + sb.Append($" \n"); + } + } + + sb.Append(""); + } + + if (scripts is { Count: > 0 }) + { + sb.Append("\n\n\n"); + foreach (var script in scripts) + { + var parametersSchema = script.ParametersSchema; + + if (script.Description is null && parametersSchema is null) + { + sb.Append($" \n"); + } + } + + sb.Append(""); + } + + return sb.ToString(); + } + + /// + /// Escapes XML special characters: always escapes &, <, >, + /// ", and '. When is , + /// quotes are left unescaped to preserve readability of embedded content such as JSON. + /// + /// The string to escape. + /// + /// When , leaves " and ' unescaped for use in XML element content (e.g., JSON). + /// When (default), escapes all XML special characters including quotes. + /// + private static string EscapeXmlString(string value, bool preserveQuotes = false) + { + var result = value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">"); + + if (!preserveQuotes) + { + result = result + .Replace("\"", """) + .Replace("'", "'"); + } + + return result; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index acb4f4780b..e851c6f9fc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -36,7 +36,7 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript /// /// Gets the JSON schema describing the parameters accepted by this script, or if not available. /// - public JsonElement? ParametersSchema => this._function.JsonSchema; + public override JsonElement? ParametersSchema => this._function.JsonSchema; /// public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs b/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs index 6316c6f607..2d4d02e3bf 100644 --- a/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs +++ b/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs @@ -26,7 +26,6 @@ internal static class DiagnosticIds // We use the same IDs so consumers do not need to suppress additional diagnostics // when using the experimental OpenAI APIs. internal const string AIOpenAIResponses = "OPENAI001"; - internal const string AIOpenAIAssistants = "OPENAI001"; private const string MEAIExperiments = "MEAI001"; } diff --git a/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs b/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs index c2a2770226..4a84192f60 100644 --- a/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs +++ b/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs @@ -12,13 +12,13 @@ namespace Shared.Foundry; internal static class AgentFactory { - public static async ValueTask CreateAgentAsync( + public static async ValueTask CreateAgentAsync( this AIProjectClient aiProjectClient, string agentName, - AgentDefinition agentDefinition, + ProjectsAgentDefinition agentDefinition, string agentDescription) { - AgentVersionCreationOptions options = + ProjectsAgentVersionCreationOptions options = new(agentDefinition) { Description = agentDescription, @@ -29,7 +29,7 @@ internal static class AgentFactory }, }; - AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, options).ConfigureAwait(false); + ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync(agentName, options).ConfigureAwait(false); Console.ForegroundColor = ConsoleColor.Cyan; try diff --git a/dotnet/src/Shared/IntegrationTests/TestSettings.cs b/dotnet/src/Shared/IntegrationTests/TestSettings.cs index 880db9d1cd..de5757314c 100644 --- a/dotnet/src/Shared/IntegrationTests/TestSettings.cs +++ b/dotnet/src/Shared/IntegrationTests/TestSettings.cs @@ -16,6 +16,7 @@ internal static class TestSettings // Azure AI (Foundry) public const string AzureAIBingConnectionId = "AZURE_AI_BING_CONNECTION_ID"; + public const string AzureAIEmbeddingDeploymentName = "AZURE_AI_EMBEDDING_DEPLOYMENT_NAME"; public const string AzureAIMemoryStoreId = "AZURE_AI_MEMORY_STORE_ID"; public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME"; public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT"; diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs deleted file mode 100644 index 0e507e44c1..0000000000 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using AgentConformance.IntegrationTests; - -namespace AzureAI.IntegrationTests; - -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) -{ - public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() - { - Assert.Skip("No messages is not supported"); - return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); - } -} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs deleted file mode 100644 index a0ee72ebd4..0000000000 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using AgentConformance.IntegrationTests; - -namespace AzureAI.IntegrationTests; - -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests(() => new()) -{ - public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() - { - Assert.Skip("No messages is not supported"); - return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); - } -} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj b/dotnet/tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj similarity index 83% rename from dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj rename to dotnet/tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj index 2703360cb2..dbff50104c 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj +++ b/dotnet/tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj @@ -7,7 +7,7 @@ - + diff --git a/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunStreamingTests.cs new file mode 100644 index 0000000000..c9128050fc --- /dev/null +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunStreamingTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace Foundry.IntegrationTests; + +public class FoundryVersionedAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunTests.cs new file mode 100644 index 0000000000..fff2ad529f --- /dev/null +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace Foundry.IntegrationTests; + +public class FoundryVersionedAgentChatClientRunTests() : ChatClientAgentRunTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentCreateTests.cs similarity index 64% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentCreateTests.cs index bc5f38acf2..329973a194 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentCreateTests.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods - using System; using System.IO; using System.Threading.Tasks; @@ -9,45 +7,43 @@ using AgentConformance.IntegrationTests.Support; using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Files; using OpenAI.Responses; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -[Obsolete("Use FoundryVersionedAgentCreateTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientCreateTests +/// +/// Integration tests for versioned creation via +/// AIProjectClient.AgentAdministrationClient.CreateAgentVersionAsync and AIProjectClient.AsAIAgent(ProjectsAgentVersion). +/// +public class FoundryVersionedAgentCreateTests { private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - [Theory] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithFoundryOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism) + [Fact] + public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync() { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("IntegrationTestAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("IntegrationTestAgent"); const string AgentDescription = "An agent created during integration tests"; const string AgentInstructions = "You are an integration test agent"; // Act. - var agent = createMechanism switch - { - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - options: new ChatClientAgentOptions() + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + AgentName, + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - Name = AgentName, - Description = AgentDescription, - ChatOptions = new() { Instructions = AgentInstructions } - }), - "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( - name: AgentName, - creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { Instructions = AgentInstructions }) { Description = AgentDescription }), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") - }; + Instructions = AgentInstructions + }) + { + Description = AgentDescription + }); + + var agent = this._client.AsAIAgent(agentVersion); try { @@ -57,27 +53,26 @@ public class AIProjectClientCreateTests Assert.Equal(AgentDescription, agent.Description); Assert.Equal(AgentInstructions, agent.GetService()!.Instructions); - var agentRecord = await this._client.Agents.GetAgentAsync(agent.Name); + var agentRecord = await this._client.AgentAdministrationClient.GetAgentAsync(agent.Name); Assert.NotNull(agentRecord); Assert.Equal(AgentName, agentRecord.Value.Name); - var definition = Assert.IsType(agentRecord.Value.GetLatestVersion().Definition); + var definition = Assert.IsType(agentRecord.Value.GetLatestVersion().Definition); Assert.Equal(AgentDescription, agentRecord.Value.GetLatestVersion().Description); Assert.Equal(AgentInstructions, definition.Instructions); } finally { // Cleanup. - await this._client.Agents.DeleteAgentAsync(agent.Name); + await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name); } } [Theory(Skip = "For manual testing only")] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithFoundryOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism) + [InlineData("FileSearchTool")] + public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string _) { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("VectorStoreAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("VectorStoreAgent"); const string AgentInstructions = """ You are a helpful agent that can help fetch data from files you know about. Use the File Search Tool to look up codes for words. @@ -99,22 +94,19 @@ public class AIProjectClientCreateTests ); var vectorStoreMetadata = await projectOpenAIClient.GetProjectVectorStoresClient().CreateVectorStoreAsync(options: new() { FileIds = { uploadedAgentFile.Id }, Name = "WordCodeLookup_VectorStore" }); - // Act. - var agent = createMechanism switch + // Act — create agent version with FileSearch tool via native SDK, then wrap with AsAIAgent. + var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]), - "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + Instructions = AgentInstructions, + Tools = { ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]) } }; + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + AgentName, + new ProjectsAgentVersionCreationOptions(definition)); + + var agent = this._client.AsAIAgent(agentVersion); + try { // Assert. @@ -125,20 +117,18 @@ public class AIProjectClientCreateTests finally { // Cleanup. - await this._client.Agents.DeleteAgentAsync(agent.Name); + await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name); await projectOpenAIClient.GetProjectVectorStoresClient().DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id); await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedAgentFile.Id); File.Delete(searchFilePath); } } - [Theory] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithFoundryOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism) + [Fact] + public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync() { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("CodeInterpreterAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("CodeInterpreterAgent"); const string AgentInstructions = """ You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file and report the SECRET_NUMBER value it prints. Respond only with the number. @@ -158,24 +148,19 @@ public class AIProjectClientCreateTests purpose: FileUploadPurpose.Assistants ); - // Act. - var agent = createMechanism switch + // Act — create agent version with CodeInterpreter tool via native SDK, then wrap with AsAIAgent. + var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - // Hosted tool path (tools supplied via ChatClientAgentOptions) - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]), - // Foundry (definitions + resources provided directly) - "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + Instructions = AgentInstructions, + Tools = { ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))) } }; + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + AgentName, + new ProjectsAgentVersionCreationOptions(definition)); + + var agent = this._client.AsAIAgent(agentVersion); + try { // Assert. @@ -186,7 +171,7 @@ public class AIProjectClientCreateTests finally { // Cleanup. - await this._client.Agents.DeleteAgentAsync(agent.Name); + await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name); await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedCodeFile.Id); File.Delete(codeFilePath); } @@ -202,7 +187,7 @@ public class AIProjectClientCreateTests public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync() { // Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("OpenAPITestAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("OpenAPITestAgent"); const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; const string CountriesOpenApiSpec = """ @@ -267,14 +252,14 @@ public class AIProjectClientCreateTests Description = "Retrieve information about countries by currency code" }; - var definition = new PromptAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + var definition = new DeclarativeAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { Instructions = AgentInstructions, - Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) } + Tools = { (ResponseTool)ProjectsAgentTool.CreateOpenApiTool(openApiFunction) } }; - AgentVersionCreationOptions creationOptions = new(definition); - AgentVersion agentVersion = await this._client.Agents.CreateAgentVersionAsync(AgentName, creationOptions); + ProjectsAgentVersionCreationOptions creationOptions = new(definition); + ProjectsAgentVersion agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(AgentName, creationOptions); try { @@ -284,7 +269,7 @@ public class AIProjectClientCreateTests // Assert the agent was created correctly and retains version metadata. Assert.NotNull(agent); Assert.Equal(AgentName, agent.Name); - var retrievedVersion = agent.GetService(); + var retrievedVersion = agent.GetService(); Assert.NotNull(retrievedVersion); // Step 3: Call RunAsync to trigger the server-side OpenAPI function. @@ -316,32 +301,33 @@ public class AIProjectClientCreateTests finally { // Cleanup. - await this._client.Agents.DeleteAgentAsync(AgentName); + await this._client.AgentAdministrationClient.DeleteAgentAsync(AgentName); } } - [Theory] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) + [Fact] + public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync() { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("WeatherAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("WeatherAgent"); const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; var weatherFunction = AIFunctionFactory.Create(GetWeather); - FoundryAgent agent = createMechanism switch + // Create agent version with the function tool registered in the server-side definition, + // then wrap with AsAIAgent passing the local AIFunction implementation. + var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - options: new ChatClientAgentOptions() - { - Name = AgentName, - ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] } - }), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + Instructions = AgentInstructions, }; + definition.Tools.Add(weatherFunction.AsOpenAIResponseTool()); + + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + AgentName, + new ProjectsAgentVersionCreationOptions(definition)); + + FoundryAgent agent = this._client.AsAIAgent(agentVersion, tools: [weatherFunction]); try { @@ -356,7 +342,7 @@ public class AIProjectClientCreateTests } finally { - await this._client.Agents.DeleteAgentAsync(agent.Name); + await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name); } } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentFixture.cs similarity index 67% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentFixture.cs index 112c76571b..cceacfa40b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentFixture.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods - using System; using System.Collections.Generic; using System.Linq; @@ -10,16 +8,21 @@ using AgentConformance.IntegrationTests; using AgentConformance.IntegrationTests.Support; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -[Obsolete("Use FoundryVersionedAgentFixture instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientFixture : IChatClientAgentFixture +/// +/// Integration test fixture that creates versioned Foundry agents via +/// AIProjectClient.AgentAdministrationClient.CreateAgentVersionAsync and wraps them +/// with AIProjectClient.AsAIAgent(ProjectsAgentVersion). +/// +public class FoundryVersionedAgentFixture : IChatClientAgentFixture { private FoundryAgent _agent = null!; private AIProjectClient _client = null!; @@ -40,7 +43,6 @@ public class AIProjectClientFixture : IChatClientAgentFixture if (chatClientSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) { - // Conversation sessions do not persist message history. return await this.GetChatHistoryFromConversationAsync(chatClientSession.ConversationId); } @@ -119,21 +121,55 @@ public class AIProjectClientFixture : IChatClientAgentFixture string instructions = "You are a helpful assistant.", IList? aiTools = null) { - return (await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: instructions, tools: aiTools)).GetService()!; + var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = instructions + }; + + // Register AIFunction tool definitions in the server-side agent definition so the model + // can invoke them. The local AIFunction implementations are matched by name via AsAIAgent. + if (aiTools is not null) + { + foreach (var tool in aiTools) + { + if (tool.AsOpenAIResponseTool() is ResponseTool responseTool) + { + definition.Tools.Add(responseTool); + } + } + } + + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + GenerateUniqueAgentName(name), + new ProjectsAgentVersionCreationOptions(definition)); + + return this._client.AsAIAgent(agentVersion, tools: aiTools).GetService()!; } public async Task CreateChatClientAgentAsync(ChatClientAgentOptions options) { options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); - return (await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options)).GetService()!; + var definition = new DeclarativeAgentDefinition( + options.ChatOptions?.ModelId ?? TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = options.ChatOptions?.Instructions + }; + + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + options.Name, + new ProjectsAgentVersionCreationOptions(definition) { Description = options.Description }); + + var agent = this._client.AsAIAgent(agentVersion, tools: options.ChatOptions?.Tools); + + return agent.GetService()!; } public static string GenerateUniqueAgentName(string baseName) => $"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; public Task DeleteAgentAsync(ChatClientAgent agent) => - this._client.Agents.DeleteAgentAsync(agent.Name); + this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name); public async Task DeleteSessionAsync(AgentSession session) { @@ -165,7 +201,7 @@ public class AIProjectClientFixture : IChatClientAgentFixture if (this._client is not null && this._agent is not null) { - return new ValueTask(this._client.Agents.DeleteAgentAsync(this._agent.Name)); + return new ValueTask(this._client.AgentAdministrationClient.DeleteAgentAsync(this._agent.Name)); } return default; @@ -174,13 +210,33 @@ public class AIProjectClientFixture : IChatClientAgentFixture public virtual async ValueTask InitializeAsync() { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - this._agent = await this._client.CreateAIAgentAsync(GenerateUniqueAgentName("HelpfulAssistant"), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: "You are a helpful assistant."); + + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + GenerateUniqueAgentName("HelpfulAssistant"), + new ProjectsAgentVersionCreationOptions( + new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = "You are a helpful assistant." + })); + + this._agent = this._client.AsAIAgent(agentVersion); } public async Task InitializeAsync(ChatClientAgentOptions options) { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); - this._agent = await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options); + + var definition = new DeclarativeAgentDefinition( + options.ChatOptions?.ModelId ?? TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = options.ChatOptions?.Instructions + }; + + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + options.Name, + new ProjectsAgentVersionCreationOptions(definition) { Description = options.Description }); + + this._agent = this._client.AsAIAgent(agentVersion, tools: options.ChatOptions?.Tools); } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunStreamingTests.cs similarity index 57% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunStreamingTests.cs index ad10ec5b7a..f5df9f1f42 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunStreamingTests.cs @@ -5,11 +5,9 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) +public class FoundryVersionedAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() { @@ -18,8 +16,7 @@ public class AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) +public class FoundryVersionedAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => { diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunTests.cs similarity index 56% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunTests.cs index 0f2b123fd9..9cefbd0f46 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunTests.cs @@ -5,11 +5,9 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) +public class FoundryVersionedAgentRunPreviousResponseTests() : RunTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() { @@ -18,8 +16,7 @@ public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStream } } -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) +public class FoundryVersionedAgentRunConversationTests() : RunTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => { diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs similarity index 71% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs index b3782a6601..015877df05 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs @@ -1,25 +1,23 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests; using AgentConformance.IntegrationTests.Support; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentStructuredOutputRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new AIProjectClientStructuredOutputFixture()) +public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new FoundryVersionedAgentStructuredOutputFixture()) { - private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time."; + private const string NotSupported = "Versioned Foundry agents do not support specifying structured output type at invocation time."; + private const string ResponseFormatNotSupported = "AzureAIProjectChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition."; /// /// Verifies that response format provided at agent initialization is used when invoking RunAsync. /// /// - [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)] public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() { // Arrange @@ -39,14 +37,14 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu } /// - /// Verifies that generic RunAsync works with AIProjectClient when structured output is configured at agent initialization. + /// Verifies that generic RunAsync works with versioned Foundry agents when structured output is configured at agent initialization. /// /// - /// AIProjectClient does not support specifying the structured output type at invocation time yet. + /// Versioned Foundry agents do not support specifying the structured output type at invocation time yet. /// The type T provided to RunAsync<T> is ignored by AzureAIProjectChatClient and is only used /// for deserializing the agent response by AgentResponse<T>.Result. /// - [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)] public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() { // Arrange @@ -88,10 +86,9 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu } /// -/// Represents a fixture for testing AIProjectClient with structured output of type provided at agent initialization. +/// Represents a fixture for testing versioned Foundry agents with structured output of type provided at agent initialization. /// -[Obsolete("Use FoundryVersionedAgentStructuredOutputFixture instead.")] -public class AIProjectClientStructuredOutputFixture : AIProjectClientFixture +public class FoundryVersionedAgentStructuredOutputFixture : FoundryVersionedAgentFixture { public override async ValueTask InitializeAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Foundry.IntegrationTests/Memory/FoundryMemoryProviderTests.cs similarity index 56% rename from dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs rename to dotnet/tests/Foundry.IntegrationTests/Memory/FoundryMemoryProviderTests.cs index d6092f5231..2904d207cd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/Memory/FoundryMemoryProviderTests.cs @@ -1,14 +1,18 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods - using System; using System.Threading.Tasks; using Azure.AI.Projects; +using Azure.AI.Projects.Memory; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; +using OpenAI.Responses; using Shared.IntegrationTests; -namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests; +namespace Foundry.IntegrationTests.Memory; /// /// Integration tests for against a configured Azure AI Foundry Memory service. @@ -16,7 +20,6 @@ namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests; /// /// These integration tests are skipped by default and require a live Azure AI Foundry Memory service. /// The tests need to be updated to use the new AIAgent-based API pattern. -/// Set to null to enable them after configuring the service. /// public sealed class FoundryMemoryProviderTests : IDisposable { @@ -25,6 +28,7 @@ public sealed class FoundryMemoryProviderTests : IDisposable private readonly AIProjectClient? _client; private readonly string? _memoryStoreName; private readonly string? _deploymentName; + private readonly string? _embeddingDeploymentName; private bool _disposed; public FoundryMemoryProviderTests() @@ -38,13 +42,15 @@ public sealed class FoundryMemoryProviderTests : IDisposable var endpoint = configuration[TestSettings.AzureAIProjectEndpoint]; var memoryStoreName = configuration[TestSettings.AzureAIMemoryStoreId]; var deploymentName = configuration[TestSettings.AzureAIModelDeploymentName]; + var embeddingDeploymentName = configuration[TestSettings.AzureAIEmbeddingDeploymentName]; if (!string.IsNullOrWhiteSpace(endpoint) && !string.IsNullOrWhiteSpace(memoryStoreName)) { - this._client = new AIProjectClient(new Uri(endpoint), TestAzureCliCredentials.CreateAzureCliCredential()); + this._client = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); this._memoryStoreName = memoryStoreName; this._deploymentName = deploymentName ?? "gpt-4.1-mini"; + this._embeddingDeploymentName = embeddingDeploymentName ?? "text-embedding-ada-002"; } } @@ -57,8 +63,17 @@ public sealed class FoundryMemoryProviderTests : IDisposable this._memoryStoreName!, stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1"))); - AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!, - options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] }); + await memoryProvider.EnsureMemoryStoreCreatedAsync(this._deploymentName!, this._embeddingDeploymentName!); + + AIAgent agent = this._client!.AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = this._deploymentName!, + Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details." + }, + AIContextProviders = [memoryProvider] + }); AgentSession session = await agent.CreateSessionAsync(); @@ -72,6 +87,15 @@ public sealed class FoundryMemoryProviderTests : IDisposable await memoryProvider.WhenUpdatesCompletedAsync(); await Task.Delay(2000); + // Assert - verify memories were actually created in the store before querying via agent + var searchResult = await this._client!.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName!, + new MemorySearchOptions("it-user-1") + { + Items = { ResponseItem.CreateUserMessageItem("Caoimhe") } + }); + Assert.NotEmpty(searchResult.Value.Memories); + AgentResponse resultAfter = await agent.RunAsync("What is my name?", session); // Cleanup @@ -95,10 +119,27 @@ public sealed class FoundryMemoryProviderTests : IDisposable this._memoryStoreName!, stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b"))); - AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!, - options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] }); - AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!, - options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] }); + await memoryProvider1.EnsureMemoryStoreCreatedAsync(this._deploymentName!, this._embeddingDeploymentName!); + + AIAgent agent1 = this._client!.AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = this._deploymentName!, + Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details." + }, + AIContextProviders = [memoryProvider1] + }); + + AIAgent agent2 = this._client!.AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = this._deploymentName!, + Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details." + }, + AIContextProviders = [memoryProvider2] + }); AgentSession session1 = await agent1.CreateSessionAsync(); AgentSession session2 = await agent2.CreateSessionAsync(); @@ -111,8 +152,25 @@ public sealed class FoundryMemoryProviderTests : IDisposable await memoryProvider1.WhenUpdatesCompletedAsync(); await Task.Delay(2000); - AgentResponse result1 = await agent1.RunAsync("What is your name?", session1); - AgentResponse result2 = await agent2.RunAsync("What is your name?", session2); + // Assert - verify memories were created in scope A but not in scope B + var searchResultA = await this._client!.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName!, + new MemorySearchOptions("it-scope-a") + { + Items = { ResponseItem.CreateUserMessageItem("Caoimhe") } + }); + Assert.NotEmpty(searchResultA.Value.Memories); + + var searchResultB = await this._client.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName!, + new MemorySearchOptions("it-scope-b") + { + Items = { ResponseItem.CreateUserMessageItem("Caoimhe") } + }); + Assert.Empty(searchResultB.Value.Memories); + + AgentResponse result1 = await agent1.RunAsync("What is my name?", session1); + AgentResponse result2 = await agent2.RunAsync("What is my name?", session2); // Assert Assert.Contains("Caoimhe", result1.Text); diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs similarity index 93% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs index 5895ceb8b9..c07509e04e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunTests.cs similarity index 92% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunTests.cs index d80b25deb2..100a3c001b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunTests.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentChatClientRunTests() : ChatClientAgentRunTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentExtensionCreateTests.cs similarity index 79% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentExtensionCreateTests.cs index cd8dc6cb03..af358a9e55 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentExtensionCreateTests.cs @@ -3,13 +3,13 @@ using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; /// /// Integration tests for non-versioned creation via extension methods. @@ -30,16 +30,19 @@ public class ResponsesAgentExtensionCreateTests const string AgentDescription = "Integration test agent created from AIProjectClient.AsAIAgent(model, instructions)."; const string VerificationToken = "integration-extension-ok"; - FoundryAgent agent = this._client.AsAIAgent( + ChatClientAgent agent = this._client.AsAIAgent( model: Model, instructions: $"You are a helpful assistant. When asked for verification, reply with exactly '{VerificationToken}'.", name: AgentName, description: AgentDescription); - AgentSession session = await agent.CreateSessionAsync(); + AgentSession? session = null; try { + var conversation = await CreateConversationAsync(this._client); + session = await agent.CreateSessionAsync(conversation.Id); + // Act AgentResponse response = await agent.RunAsync("Return the verification token.", session); @@ -47,7 +50,6 @@ public class ResponsesAgentExtensionCreateTests Assert.NotNull(agent); Assert.Equal(AgentName, agent.Name); Assert.Equal(AgentDescription, agent.Description); - Assert.Same(this._client, agent.GetService()); Assert.NotNull(agent.GetService()); Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase); } @@ -73,19 +75,22 @@ public class ResponsesAgentExtensionCreateTests }, }; - FoundryAgent agent = this._client.AsAIAgent(options); - ChatClientAgentSession session = await agent.CreateConversationSessionAsync(); + ChatClientAgent agent = this._client.AsAIAgent(options); + + ChatClientAgentSession? session = null; try { + var conversation = await CreateConversationAsync(this._client); + session = ((await agent.CreateSessionAsync(conversation.Id)) as ChatClientAgentSession)!; + // Act AgentResponse response = await agent.RunAsync("Return the verification token.", session); // Assert - Assert.StartsWith("conv_", session.ConversationId, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith("conv_", session!.ConversationId, StringComparison.OrdinalIgnoreCase); Assert.Equal(options.Name, agent.Name); Assert.Equal(options.Description, agent.Description); - Assert.Same(this._client, agent.GetService()); Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase); } finally @@ -94,8 +99,13 @@ public class ResponsesAgentExtensionCreateTests } } - private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession session) + private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession? session) { + if (session is null) + { + return; + } + ChatClientAgentSession typedSession = (ChatClientAgentSession)session; if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) @@ -119,4 +129,10 @@ public class ResponsesAgentExtensionCreateTests await DeleteResponseChainAsync(client, response.Value.PreviousResponseId); } } + + private static async Task CreateConversationAsync(AIProjectClient client) + { + ProjectConversationsClient conversationsClient = client.GetProjectOpenAIClient().GetProjectConversationsClient(); + return (await conversationsClient.CreateProjectConversationAsync()).Value!; + } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentFixture.cs similarity index 98% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentFixture.cs index ec678a00d9..7bd0da0d95 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentFixture.cs @@ -9,19 +9,18 @@ using AgentConformance.IntegrationTests.Support; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using OpenAI.Responses; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; /// /// Integration test fixture that creates non-versioned Responses agents via the direct AIProjectClient.AsAIAgent(...) path. /// public class ResponsesAgentFixture : IChatClientAgentFixture { - private FoundryAgent _agent = null!; + private ChatClientAgent _agent = null!; private AIProjectClient _client = null!; public IChatClient ChatClient => this._agent.GetService()!.ChatClient; diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunStreamingTests.cs similarity index 96% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunStreamingTests.cs index 5f21c316c4..09f5fe6b2e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunStreamingTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunTests.cs similarity index 96% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunTests.cs index 71460f7737..0635b0f4ac 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentRunPreviousResponseTests() : RunTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs similarity index 99% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs index 19ebdb4e28..a561fbae1b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs @@ -7,7 +7,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new()) { diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs deleted file mode 100644 index 5c954d30e8..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs +++ /dev/null @@ -1,3472 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Microsoft.Extensions.AI; -using Moq; -using OpenAI.Responses; - -namespace Microsoft.Agents.AI.AzureAI.UnitTests; - -#pragma warning disable CS0618 -/// -/// Unit tests for the class. -/// -[Obsolete("Includes coverage for obsolete AIProjectClient compatibility extension methods.")] -public sealed class AzureAIProjectChatClientExtensionsTests -{ - #region AsAIAgent(AIProjectClient, model, instructions) Tests - - /// - /// Verify that the non-versioned AsAIAgent overload throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithModelAndInstructions_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - client!.AsAIAgent("gpt-4o-mini", "You are helpful.")); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that the non-versioned AsAIAgent overload creates a valid ChatClientAgent. - /// - [Fact] - public void AsAIAgent_WithModelAndInstructions_CreatesChatClientAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - List tools = - [ - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - ]; - - // Act - FoundryAgent agent = client.AsAIAgent( - "gpt-4o-mini", - "You are helpful.", - name: "test-agent", - description: "A test agent", - tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - Assert.Equal("A test agent", agent.Description); - Assert.Same(client, agent.GetService()); - Assert.NotNull(agent.GetService()); - } - - /// - /// Verify that the non-versioned AsAIAgent overload applies the clientFactory. - /// - [Fact] - public void AsAIAgent_WithModelAndInstructions_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - TestChatClient? testChatClient = null; - - // Act - FoundryAgent agent = client.AsAIAgent( - "gpt-4o-mini", - "You are helpful.", - clientFactory: innerClient => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - TestChatClient? retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that the options-based non-versioned AsAIAgent overload creates a valid ChatClientAgent. - /// - [Fact] - public void AsAIAgent_WithOptions_CreatesChatClientAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - ChatClientAgentOptions options = new() - { - Name = "options-agent", - Description = "Agent from options", - ChatOptions = new ChatOptions - { - ModelId = "gpt-4o-mini", - Instructions = "You are helpful.", - }, - }; - - // Act - FoundryAgent agent = client.AsAIAgent(options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("options-agent", agent.Name); - Assert.Equal("Agent from options", agent.Description); - Assert.Same(client, agent.GetService()); - } - - /// - /// Verify that the non-versioned AsAIAgent overload adds the MEAI user-agent header to Responses API requests. - /// - [Fact] - public async Task AsAIAgent_WithModelAndInstructions_UserAgentHeaderAddedToResponsesRequestsAsync() - { - // Arrange - bool userAgentFound = false; - using HttpHandlerAssert httpHandler = new(request => - { - if (request.Headers.TryGetValues("User-Agent", out IEnumerable? values)) - { - foreach (string value in values) - { - if (value.Contains("MEAI")) - { - userAgentFound = true; - } - } - } - - if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - TestDataUtil.GetOpenAIDefaultResponseJson(), - Encoding.UTF8, - "application/json") - }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{}", Encoding.UTF8, "application/json") - }; - }); - -#pragma warning disable CA5399 - using HttpClient httpClient = new(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient aiProjectClient = new( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - FoundryAgent agent = aiProjectClient.AsAIAgent( - "gpt-4o-mini", - "You are helpful."); - - // Act - AgentSession session = await agent.CreateSessionAsync(); - await agent.RunAsync("Hello", session); - - // Assert - Assert.True(userAgentFound, "MEAI user-agent header was not found in any request"); - } - - #endregion - - #region AsAIAgent(AIProjectClient, AgentRecord) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent(agentRecord)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when agentRecord is null. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_WithNullAgentRecord_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((AgentRecord)null!)); - - Assert.Equal("agentRecord", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with AgentRecord creates a valid agent. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_CreatesValidAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - - // Assert - Assert.NotNull(agent); - Assert.Equal("agent_abc123", agent.Name); - } - - /// - /// Verify that AsAIAgent with AgentRecord and clientFactory applies the factory. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - TestChatClient? testChatClient = null; - - // Act - var agent = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - #endregion - - #region AsAIAgent(AIProjectClient, AgentVersion) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent(agentVersion)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when agentVersion is null. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithNullAgentVersion_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((AgentVersion)null!)); - - Assert.Equal("agentVersion", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with AgentVersion creates a valid agent. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_CreatesValidAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - Assert.Equal("agent_abc123", agent.Name); - } - - /// - /// Verify that AsAIAgent with AgentVersion and clientFactory applies the factory. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - TestChatClient? testChatClient = null; - - // Act - var agent = client.AsAIAgent( - agentVersion, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that AsAIAgent with requireInvocableTools=true enforces invocable tools. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsTrue_EnforcesInvocableTools() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = client.AsAIAgent(agentVersion, tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that AsAIAgent with requireInvocableTools=false allows declarative functions. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsFalse_AllowsDeclarativeFunctions() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - should not throw even without tools when requireInvocableTools is false - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region GetAIAgentAsync(AIProjectClient, ChatClientAgentOptions) Tests - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentNullException when client is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AIProjectClient? client = null; - var options = new ChatClientAgentOptions { Name = "test-agent" }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client!.GetAIAgentAsync(options)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentNullException when options is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullOptions_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync((ChatClientAgentOptions)null!)); - - Assert.Equal("options", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions creates a valid agent. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_CreatesValidAgentAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent"); - var options = new ChatClientAgentOptions { Name = "test-agent" }; - - // Act - var agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - } - - #endregion - - #region AsAIAgent(AIProjectClient, string) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_ByName_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent("test-agent")); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when name is null. - /// - [Fact] - public void AsAIAgent_ByName_WithNullName_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((string)null!)); - - Assert.Equal("name", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentException when name is empty. - /// - [Fact] - public void AsAIAgent_ByName_WithEmptyName_ThrowsArgumentException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent(string.Empty)); - - Assert.Equal("name", exception.ParamName); - } - - #endregion - - #region GetAIAgentAsync(AIProjectClient, string) Tests - - /// - /// Verify that GetAIAgentAsync throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public async Task GetAIAgentAsync_ByName_WithNullClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AIProjectClient? client = null; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client!.GetAIAgentAsync("test-agent")); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync throws ArgumentNullException when name is null. - /// - [Fact] - public async Task GetAIAgentAsync_ByName_WithNullName_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync(name: null!)); - - Assert.Equal("name", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync throws InvalidOperationException when agent is not found. - /// - [Fact] - public async Task GetAIAgentAsync_ByName_WithNonExistentAgent_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - var mockAgentOperations = new Mock(); - mockAgentOperations - .Setup(c => c.GetAgentAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null")))); - - var mockClient = new Mock(); - mockClient.SetupGet(c => c.Agents).Returns(mockAgentOperations.Object); - mockClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync("non-existent-agent")); - - Assert.Contains("not found", exception.Message); - } - - #endregion - - #region AsAIAgent(AIProjectClient, AgentRecord) with tools Tests - - /// - /// Verify that AsAIAgent with additional tools when the definition has no tools does not throw and results in an agent with no tools. - /// - [Fact] - public void AsAIAgent_WithAgentRecordAndAdditionalTools_WhenDefinitionHasNoTools_ShouldNotThrow() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = client.AsAIAgent(agentRecord, tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var agentVersion = chatClient.GetService(); - Assert.NotNull(agentVersion); - var definition = Assert.IsType(agentVersion.Definition); - Assert.Empty(definition.Tools); - } - - /// - /// Verify that AsAIAgent with null tools works correctly. - /// - [Fact] - public void AsAIAgent_WithAgentRecordAndNullTools_WorksCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord, tools: null); - - // Assert - Assert.NotNull(agent); - Assert.Equal("agent_abc123", agent.Name); - } - - #endregion - - #region GetAIAgentAsync(AIProjectClient, string) with tools Tests - - /// - /// Verify that GetAIAgentAsync with tools parameter creates an agent. - /// - [Fact] - public async Task GetAIAgentAsync_WithNameAndTools_CreatesAgentAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = await client.GetAIAgentAsync("test-agent", tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with model and options creates a valid agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_CreatesValidAgentAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", instructions: "Test instructions"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" } - }; - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - Assert.Equal("Test instructions", agent.GetService()!.Instructions); - } - - /// - /// Verify that CreateAIAgentAsync with model and options and clientFactory applies the factory. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithClientFactory_AppliesFactoryCorrectlyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", instructions: "Test instructions"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" } - }; - TestChatClient? testChatClient = null; - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-model", - options, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - #endregion - - #region CreateAIAgentAsync(AIProjectClient, string, AgentDefinition) Tests - - /// - /// Verify that CreateAIAgentAsync throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithAgentDefinition_WithNullClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AIProjectClient? client = null; - var definition = new PromptAgentDefinition("test-model"); - var options = new AgentVersionCreationOptions(definition); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client!.CreateAIAgentAsync("agent-name", options)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that CreateAIAgentAsync throws ArgumentNullException when creationOptions is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithAgentDefinition_WithNullDefinition_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.CreateAIAgentAsync(name: "agent-name", null!)); - - Assert.Equal("creationOptions", exception.ParamName); - } - - #endregion - - #region Tool Validation Tests - - /// - /// Verify that CreateAIAgent creates an agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgent without tools parameter creates an agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithoutToolsParameter_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - var definitionResponse = GeneratePromptDefinitionResponse(definition, null); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgent without tools in definition creates an agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithoutToolsInDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definition); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgent uses tools from the definition when no separate tools parameter is provided. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDefinitionTools_UsesDefinitionToolsAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - // Add a function tool to the definition - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - - // Create a response definition with the same tool - var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Single(promptDef.Tools); - Assert.Equal("required_tool", (promptDef.Tools.First() as FunctionTool)?.FunctionName); - } - } - - /// - /// Verify that CreateAIAgent creates an agent successfully when definition has a mix of custom and hosted tools. - /// - [Fact] - public async Task CreateAIAgentAsync_WithMixedToolsInDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); - definition.Tools.Add(new HostedFileSearchTool().GetService() ?? new HostedFileSearchTool().AsOpenAIResponseTool()); - - // Simulate agent definition response with the tools - var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - foreach (var tool in definition.Tools) - { - definitionResponse.Tools.Add(tool); - } - - using var testClient = CreateTestAgentClientWithHandler(agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Equal(3, promptDef.Tools.Count); - } - } - - /// - /// Verify that CreateAIAgentAsync when AI Tools are provided, uses them for the definition via http request. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNameAndAITools_SendsToolDefinitionViaHttpAsync() - { - // Arrange - using var httpHandler = new HttpHandlerAssert(async (request) => - { - if (request.Content is not null) - { - var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - - Assert.Contains("required_tool", requestBody); - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Act - var agent = await client.CreateAIAgentAsync( - name: "test-agent", - model: "test-model", - instructions: "Test", - tools: [AIFunctionFactory.Create(() => true, "required_tool")]); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - Assert.IsType(agentVersion.Definition); - } - - /// - /// Verify that when providing AITools with AsAIAgent, any additional tool that doesn't match the tools in agent definition are ignored. - /// - [Fact] - public void AsAIAgent_AdditionalAITools_WhenNotInTheDefinitionAreIgnored() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentVersion = this.CreateTestAgentVersion(); - - // Manually add tools to the definition to simulate inline tools - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - promptDef.Tools.Add(ResponseTool.CreateFunctionTool("inline_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - } - - var invocableInlineAITool = AIFunctionFactory.Create(() => "test", "inline_tool", "An invocable AIFunction for the inline function"); - var shouldBeIgnoredTool = AIFunctionFactory.Create(() => "test", "additional_tool", "An additional test function that should be ignored"); - - // Act & Assert - var agent = client.AsAIAgent(agentVersion, tools: [invocableInlineAITool, shouldBeIgnoredTool]); - Assert.NotNull(agent); - var version = agent.GetService(); - Assert.NotNull(version); - var definition = Assert.IsType(version.Definition); - Assert.NotEmpty(definition.Tools); - Assert.NotNull(GetAgentChatOptions(agent)); - Assert.NotNull(GetAgentChatOptions(agent)!.Tools); - Assert.Single(GetAgentChatOptions(agent)!.Tools!); - Assert.Equal("inline_tool", (definition.Tools.First() as FunctionTool)?.FunctionName); - } - - #endregion - - #region Inline Tools vs Parameter Tools Tests - - /// - /// Verify that tools passed as parameters are accepted by AsAIAgent. - /// - [Fact] - public void AsAIAgent_WithParameterTools_AcceptsTools() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - var tools = new List - { - AIFunctionFactory.Create(() => "tool1", "param_tool_1", "First parameter tool"), - AIFunctionFactory.Create(() => "tool2", "param_tool_2", "Second parameter tool") - }; - - // Act - var agent = client.AsAIAgent(agentRecord, tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var agentVersion = chatClient.GetService(); - Assert.NotNull(agentVersion); - } - - /// - /// Verify that CreateAIAgent with string parameters and tools creates an agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithStringParamsAndTools_CreatesAgentAsync() - { - // Arrange - var tools = new List - { - AIFunctionFactory.Create(() => "weather", "string_param_tool", "Tool from string params") - }; - - var definitionResponse = GeneratePromptDefinitionResponse(new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }, tools); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-agent", - "test-model", - "Test instructions", - tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Single(promptDef.Tools); - } - } - - /// - /// Verify that CreateAIAgentAsync with tools in definition creates an agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDefinitionTools_CreatesAgentAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("async_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that GetAIAgentAsync with tools parameter creates an agent. - /// - [Fact] - public async Task GetAIAgentAsync_WithToolsParameter_CreatesAgentAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var tools = new List - { - AIFunctionFactory.Create(() => "async_get_result", "async_get_tool", "An async get tool") - }; - - // Act - var agent = await client.GetAIAgentAsync("test-agent", tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region Declarative Function Handling Tests - - /// - /// Verifies that CreateAIAgent uses tools from definition when they are ResponseTool instances, resulting in successful agent creation. - /// - [Fact] - public async Task CreateAIAgentAsync_WithResponseToolsInDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - - var fabricToolOptions = new FabricDataAgentToolOptions(); - fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); - - var sharepointOptions = new SharePointGroundingToolOptions(); - sharepointOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); - - var structuredOutputs = new StructuredOutputDefinition("name", "description", new Dictionary { ["schema"] = BinaryData.FromString(AIJsonUtilities.CreateJsonSchema(new { id = "test" }.GetType()).ToString()) }, false); - - // Add tools to the definition - definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add((ResponseTool)AgentTool.CreateBingCustomSearchTool(new BingCustomSearchToolOptions([new BingCustomSearchConfiguration("connection-id", "instance-name")]))); - definition.Tools.Add((ResponseTool)AgentTool.CreateBrowserAutomationTool(new BrowserAutomationToolOptions(new BrowserAutomationToolConnectionParameters("id")))); - definition.Tools.Add(AgentTool.CreateA2ATool(new Uri("https://test-uri.microsoft.com"))); - definition.Tools.Add((ResponseTool)AgentTool.CreateBingGroundingTool(new BingGroundingSearchToolOptions([new BingGroundingSearchConfiguration("connection-id")]))); - definition.Tools.Add((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)); - definition.Tools.Add((ResponseTool)AgentTool.CreateOpenApiTool(new OpenApiFunctionDefinition("name", BinaryData.FromString(OpenAPISpec), new OpenAPIAnonymousAuthenticationDetails()))); - definition.Tools.Add((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)); - definition.Tools.Add((ResponseTool)AgentTool.CreateStructuredOutputsTool(structuredOutputs)); - definition.Tools.Add((ResponseTool)AgentTool.CreateAzureAISearchTool(new AzureAISearchToolOptions([new AzureAISearchToolIndex() { IndexName = "name" }]))); - - // Generate agent definition response with the tools - var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); - - using var testClient = CreateTestAgentClientWithHandler(agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Equal(10, promptDef.Tools.Count); - } - } - - /// - /// Verify that CreateAIAgentAsync accepts FunctionTools from definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithFunctionToolsInDefinition_AcceptsDeclarativeFunctionAsync() - { - // Arrange - var functionTool = ResponseTool.CreateFunctionTool( - functionName: "get_user_name", - functionParameters: BinaryData.FromString("{}"), - strictModeEnabled: false, - functionDescription: "Gets the user's name, as used for friendly address." - ); - - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - definition.Tools.Add(functionTool); - - // Generate response with the declarative function - var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - definitionResponse.Tools.Add(functionTool); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync accepts declarative functions from definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDeclarativeFunctionFromDefinition_AcceptsDeclarativeFunctionAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration - using var doc = JsonDocument.Parse("{}"); - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); - - // Add to definition - definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync accepts declarative functions from definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDeclarativeFunctionInDefinition_AcceptsDeclarativeFunctionAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration - using var doc = JsonDocument.Parse("{}"); - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); - - // Add to definition - definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); - - // Generate response with the declarative function - var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - definitionResponse.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region Options Generation Validation Tests - - /// - /// Verify that ChatClientAgentOptions are generated correctly without tools. - /// - [Fact] - public async Task CreateAIAgentAsync_GeneratesCorrectChatClientAgentOptionsAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - - var definitionResponse = GeneratePromptDefinitionResponse(definition, null); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - Assert.Equal("test-agent", agentVersion.Name); - Assert.Equal("Test instructions", (agentVersion.Definition as PromptAgentDefinition)?.Instructions); - } - - /// - /// Verify that GetAIAgentAsync with options preserves custom properties from input options. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_PreservesCustomPropertiesAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", instructions: "Custom instructions", description: "Custom description"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - Description = "Custom description", - ChatOptions = new ChatOptions { Instructions = "Custom instructions" } - }; - - // Act - var agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - Assert.Equal("Custom instructions", agent.GetService()!.Instructions); - Assert.Equal("Custom description", agent.Description); - } - - /// - /// Verify that CreateAIAgentAsync with options and tools generates correct ChatClientAgentOptions. - /// - [Fact] - public async Task CreateAIAgentAsync_WithOptionsAndTools_GeneratesCorrectOptionsAsync() - { - // Arrange - var tools = new List - { - AIFunctionFactory.Create(() => "result", "option_tool", "A tool from options") - }; - - var definitionResponse = GeneratePromptDefinitionResponse( - new PromptAgentDefinition("test-model") { Instructions = "Test" }, - tools); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test", Tools = tools } - }; - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Single(promptDef.Tools); - } - } - - #endregion - - #region AgentName Validation Tests - - /// - /// Verify that AsAIAgent throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public void AsAIAgent_ByName_WithInvalidAgentName_ThrowsArgumentException(string invalidName) - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent(invalidName)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task GetAIAgentAsync_ByName_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync(invalidName)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task GetAIAgentAsync_WithOptions_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = invalidName }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task CreateAIAgentAsync_WithBasicParams_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.CreateAIAgentAsync(invalidName, "model", "instructions")); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with AgentVersionCreationOptions throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task CreateAIAgentAsync_WithAgentDefinition_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - var mockClient = new Mock(); - var definition = new PromptAgentDefinition("test-model"); - var options = new AgentVersionCreationOptions(definition); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.CreateAIAgentAsync(invalidName, options)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with ChatClientAgentOptions throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task CreateAIAgentAsync_WithOptions_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = invalidName }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that AsAIAgent with AgentReference throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public void AsAIAgent_WithAgentReference_WithInvalidAgentName_ThrowsArgumentException(string invalidName) - { - // Arrange - var mockClient = new Mock(); - var agentReference = new AgentReference(invalidName, "1"); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent(agentReference)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - #endregion - - #region AzureAIChatClient Behavior Tests - - /// - /// Verify that the underlying chat client created by extension methods can be wrapped with clientFactory. - /// - [Fact] - public void AsAIAgent_WithClientFactory_WrapsUnderlyingChatClient() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - int factoryCallCount = 0; - - // Act - var agent = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => - { - factoryCallCount++; - return new TestChatClient(innerClient); - }); - - // Assert - Assert.NotNull(agent); - Assert.Equal(1, factoryCallCount); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - } - - /// - /// Verify that clientFactory is called with the correct underlying chat client. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactory_ReceivesCorrectUnderlyingClientAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - IChatClient? receivedClient = null; - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-agent", - options, - clientFactory: (innerClient) => - { - receivedClient = innerClient; - return new TestChatClient(innerClient); - }); - - // Assert - Assert.NotNull(agent); - Assert.NotNull(receivedClient); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - } - - /// - /// Verify that multiple clientFactory calls create independent wrapped clients. - /// - [Fact] - public void AsAIAgent_MultipleCallsWithClientFactory_CreatesIndependentClients() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent1 = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - var agent2 = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent1); - Assert.NotNull(agent2); - var client1 = agent1.GetService(); - var client2 = agent2.GetService(); - Assert.NotNull(client1); - Assert.NotNull(client2); - Assert.NotSame(client1, client2); - } - - /// - /// Verify that agent created with clientFactory maintains agent properties. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactory_PreservesAgentPropertiesAsync() - { - // Arrange - const string AgentName = "test-agent"; - const string Model = "test-model"; - const string Instructions = "Test instructions"; - using var testClient = CreateTestAgentClientWithHandler(AgentName, Instructions); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - AgentName, - Model, - Instructions, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - Assert.Equal(AgentName, agent.Name); - Assert.Equal(Instructions, agent.GetService()!.Instructions); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - } - - /// - /// Verify that agent created with clientFactory is created successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactory_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - var agentDefinitionResponse = GeneratePromptDefinitionResponse(definition, null); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: agentDefinitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-agent", - options, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - } - - #endregion - - #region User-Agent Header Tests - - /// - /// Verifies that the MEAI user-agent header is added to CreateAIAgentAsync POST requests - /// via the protocol method's RequestOptions pipeline policy. - /// - [Fact] - public async Task CreateAIAgentAsync_UserAgentHeaderAddedToRequestsAsync() - { - using var httpHandler = new HttpHandlerAssert(request => - { - Assert.Equal("POST", request.Method.Method); - - // Verify MEAI user-agent header is present on CreateAgentVersion POST request - Assert.True(request.Headers.TryGetValues("User-Agent", out var userAgentValues)); - Assert.Contains(userAgentValues, v => v.Contains("MEAI")); - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - // Arrange - var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; - - // Act - var agent = await aiProjectClient.CreateAIAgentAsync("test", agentOptions); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verifies that the user-agent header is added to asynchronous GetAIAgentAsync requests. - /// - [Fact] - public async Task GetAIAgent_UserAgentHeaderAddedToRequestsAsync() - { - using var httpHandler = new HttpHandlerAssert(request => - { - Assert.Equal("GET", request.Method.Method); - Assert.Contains("MEAI", request.Headers.UserAgent.ToString()); - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - // Arrange - var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Act - var agent = await aiProjectClient.GetAIAgentAsync("test"); - - // Assert - Assert.NotNull(agent); - } - - #endregion - - #region GetAIAgent(AIProjectClient, AgentReference) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - var agentReference = new AgentReference("test-name", "1"); - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent(agentReference)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when agentReference is null. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithNullAgentReference_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((AgentReference)null!)); - - Assert.Equal("agentReference", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with AgentReference creates a valid agent. - /// - [Fact] - public void AsAIAgent_WithAgentReference_CreatesValidAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - - // Act - var agent = client.AsAIAgent(agentReference); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-name", agent.Name); - Assert.Equal("test-name:1", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentReference and clientFactory applies the factory. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - TestChatClient? testChatClient = null; - - // Act - var agent = client.AsAIAgent( - agentReference, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that AsAIAgent with AgentReference sets the agent ID correctly. - /// - [Fact] - public void AsAIAgent_WithAgentReference_SetsAgentIdCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "2"); - - // Act - var agent = client.AsAIAgent(agentReference); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-name:2", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentReference and tools includes the tools in ChatOptions. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithTools_IncludesToolsInChatOptions() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = client.AsAIAgent(agentReference, tools: tools); - - // Assert - Assert.NotNull(agent); - var chatOptions = GetAgentChatOptions(agent); - Assert.NotNull(chatOptions); - Assert.NotNull(chatOptions.Tools); - Assert.Single(chatOptions.Tools); - } - - #endregion - - #region GetService Tests - - /// - /// Verify that GetService returns AgentRecord for agents created from AgentRecord. - /// - [Fact] - public void GetService_WithAgentRecord_ReturnsAgentRecord() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - var retrievedRecord = agent.GetService(); - - // Assert - Assert.NotNull(retrievedRecord); - Assert.Equal(agentRecord.Id, retrievedRecord.Id); - } - - /// - /// Verify that GetService returns null for AgentRecord when agent is created from AgentReference. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsNullForAgentRecord() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedRecord = agent.GetService(); - - // Assert - Assert.Null(retrievedRecord); - } - - #endregion - - #region GetService Tests - - /// - /// Verify that GetService returns AgentVersion for agents created from AgentVersion. - /// - [Fact] - public void GetService_WithAgentVersion_ReturnsAgentVersion() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - var retrievedVersion = agent.GetService(); - - // Assert - Assert.NotNull(retrievedVersion); - Assert.Equal(agentVersion.Id, retrievedVersion.Id); - } - - /// - /// Verify that GetService returns null for AgentVersion when agent is created from AgentReference. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsNullForAgentVersion() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedVersion = agent.GetService(); - - // Assert - Assert.Null(retrievedVersion); - } - - #endregion - - #region ChatClientMetadata Tests - - /// - /// Verify that ChatClientMetadata is properly populated for agents created from AgentRecord. - /// - [Fact] - public void ChatClientMetadata_WithAgentRecord_IsPopulatedCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - var metadata = agent.GetService(); - - // Assert - Assert.NotNull(metadata); - Assert.NotNull(metadata.DefaultModelId); - } - - /// - /// Verify that ChatClientMetadata.DefaultModelId is set from PromptAgentDefinition model property. - /// - [Fact] - public void ChatClientMetadata_WithPromptAgentDefinition_SetsDefaultModelIdFromModel() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var definition = new PromptAgentDefinition("gpt-4-turbo") - { - Instructions = "Test instructions" - }; - AgentRecord agentRecord = this.CreateTestAgentRecord(definition); - - // Act - var agent = client.AsAIAgent(agentRecord); - var metadata = agent.GetService(); - - // Assert - Assert.NotNull(metadata); - // The metadata should contain the model information from the agent definition - Assert.NotNull(metadata.DefaultModelId); - Assert.Equal("gpt-4-turbo", metadata.DefaultModelId); - } - - /// - /// Verify that ChatClientMetadata is properly populated for agents created from AgentVersion. - /// - [Fact] - public void ChatClientMetadata_WithAgentVersion_IsPopulatedCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - var metadata = agent.GetService(); - - // Assert - Assert.NotNull(metadata); - Assert.NotNull(metadata.DefaultModelId); - Assert.Equal((agentVersion.Definition as PromptAgentDefinition)!.Model, metadata.DefaultModelId); - } - - #endregion - - #region AgentReference Availability Tests - - /// - /// Verify that GetService returns AgentReference for agents created from AgentReference. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsAgentReference() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-agent", "1.0"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal("test-agent", retrievedReference.Name); - Assert.Equal("1.0", retrievedReference.Version); - } - - /// - /// Verify that GetService returns null for AgentReference when agent is created from AgentRecord. - /// - [Fact] - public void GetService_WithAgentRecord_ReturnsAlsoAgentReference() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal(agentRecord.Name, retrievedReference.Name); - } - - /// - /// Verify that GetService returns null for AgentReference when agent is created from AgentVersion. - /// - [Fact] - public void GetService_WithAgentVersion_ReturnsAlsoAgentReference() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal(agentVersion.Name, retrievedReference.Name); - } - - /// - /// Verify that GetService returns AgentReference with correct version information. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsCorrectVersionInformation() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("versioned-agent", "3.5"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal("versioned-agent", retrievedReference.Name); - Assert.Equal("3.5", retrievedReference.Version); - } - - #endregion - - #region GetAIAgentAsync - Empty Name Tests - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when name is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = null }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when name is empty. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithEmptyName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = string.Empty }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when name is whitespace. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithWhitespaceName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = " " }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - #endregion - - #region CreateAIAgentAsync - Empty Name Tests - - /// - /// Verify that CreateAIAgentAsync with model and options throws ArgumentException when name is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithNullName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = null, - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with model and options throws ArgumentException when name is empty. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithEmptyName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = string.Empty, - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with model and options throws ArgumentException when name is whitespace. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithWhitespaceName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = " ", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - #endregion - - #region CreateAIAgentAsync - Response Format Tests - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatText response format creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithTextResponseFormat_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = ChatResponseFormat.Text - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson response format without schema creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithoutSchema_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = ChatResponseFormat.Json - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson with schema creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchema_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - JsonElement schemaElement = AIJsonUtilities.CreateJsonSchema(typeof(TestSchema)); - var jsonFormat = ChatResponseFormat.ForJsonSchema(schemaElement, "test_schema", "A test schema"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = jsonFormat - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson with schema and strict mode creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchemaAndStrictMode_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - JsonElement schemaElement = AIJsonUtilities.CreateJsonSchema(typeof(TestSchema)); - var jsonFormat = ChatResponseFormat.ForJsonSchema(schemaElement, "test_schema", "A test schema"); - var additionalProps = new AdditionalPropertiesDictionary - { - ["strictJsonSchema"] = true - }; - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = jsonFormat, - AdditionalProperties = additionalProps - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson with schema and strict mode false creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchemaAndStrictModeFalse_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - JsonElement schemaElement = AIJsonUtilities.CreateJsonSchema(typeof(TestSchema)); - var jsonFormat = ChatResponseFormat.ForJsonSchema(schemaElement, "test_schema", "A test schema"); - var additionalProps = new AdditionalPropertiesDictionary - { - ["strictJsonSchema"] = false - }; - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = jsonFormat, - AdditionalProperties = additionalProps - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region CreateAIAgentAsync - RawRepresentationFactory Tests - - /// - /// Verify that CreateAIAgentAsync with RawRepresentationFactory that returns CreateResponseOptions creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithRawRepresentationFactory_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - RawRepresentationFactory = _ => new CreateResponseOptions() - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with RawRepresentationFactory that returns null does not fail. - /// - [Fact] - public async Task CreateAIAgentAsync_WithRawRepresentationFactoryReturningNull_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - RawRepresentationFactory = _ => null - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with RawRepresentationFactory that returns non-CreateResponseOptions does not fail. - /// - [Fact] - public async Task CreateAIAgentAsync_WithRawRepresentationFactoryReturningNonCreateResponseOptions_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - RawRepresentationFactory = _ => new object() - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region CreateAIAgentAsync - Description Tests - - /// - /// Verify that CreateAIAgentAsync with description sets description on the agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDescription_SetsDescriptionAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(description: "Test description"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - Description = "Test description", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test description", agent.Description); - } - - /// - /// Verify that CreateAIAgentAsync without description still creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithoutDescription_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - } - - #endregion - - #region CreateChatClientAgentOptions - Missing Tools Tests - - /// - /// Verify that when invocable tools are required but not provided, an exception is thrown. - /// - [Fact] - public async Task GetAIAgentAsync_WithToolsRequiredButNotProvided_ThrowsArgumentExceptionAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Contains("in-process tools must be provided", exception.Message); - } - - /// - /// Verify that when specific invocable tools are required but wrong ones are provided, InvalidOperationException is thrown. - /// - [Fact] - public async Task GetAIAgentAsync_WithWrongToolsProvided_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "wrong_function", "Wrong function") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Contains("required_function", exception.Message); - Assert.Contains("were not provided", exception.Message); - } - - /// - /// Verify that when tools are provided that match the definition, agent is created successfully. - /// - [Fact] - public async Task GetAIAgentAsync_WithMatchingToolsProvided_CreatesAgentSuccessfullyAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "required_function", "Required function") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region CreateChatClientAgentOptions - Options Preservation Tests - - /// - /// Verify that CreateChatClientAgentOptions preserves AIContextProviders. - /// - [Fact] - public async Task GetAIAgentAsync_WithAIContextProviders_PreservesProviderAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - AIContextProviders = [new TestAIContextProvider()] - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that CreateChatClientAgentOptions preserves ChatHistoryProvider. - /// - [Fact] - public async Task GetAIAgentAsync_WithChatHistoryProvider_PreservesProviderAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - ChatHistoryProvider = new TestChatHistoryProvider() - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that CreateChatClientAgentOptions preserves UseProvidedChatClientAsIs. - /// - [Fact] - public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_PreservesSettingAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - UseProvidedChatClientAsIs = true - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that GetAIAgentAsync with UseProvidedChatClientAsIs=true skips tool validation - /// and does not throw even when server-side function tools exist without matching invocable tools. - /// - [Fact] - public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_SkipsToolValidationAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - UseProvidedChatClientAsIs = true - }; - - // Act - should not throw even without tools when UseProvidedChatClientAsIs is true - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that GetAIAgentAsync with UseProvidedChatClientAsIs=true still matches provided AIFunction tools - /// to server-side function definitions, instead of falling back to the ResponseToolAITool wrapper. - /// - [Fact] - public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_PreservesProvidedToolsAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("my_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var providedTool = AIFunctionFactory.Create(() => "test", "my_function", "A test function"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - UseProvidedChatClientAsIs = true, - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = [providedTool] - }, - }; - - // Act - UseProvidedChatClientAsIs is true, but provided AIFunctions should still be matched and preserved - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - - // Verify the provided AIFunction was matched and preserved in ChatOptions.Tools (not replaced by AsAITool wrapper) - var chatOptions = agent.GetService(); - Assert.NotNull(chatOptions); - Assert.NotNull(chatOptions!.Tools); - Assert.Contains(chatOptions.Tools, t => t is AIFunction af && af.Name == "my_function"); - } - - #endregion - - #region Empty Version and ID Handling Tests - - /// - /// Verify that GetAIAgentAsync handles an agent with empty version by using "latest" as fallback. - /// - [Fact] - public async Task GetAIAgentAsync_WithEmptyVersion_CreatesAgentSuccessfullyAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - // Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentRecord handles empty version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentRecordEmptyVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); - AgentRecord agentRecord = this.CreateTestAgentRecordWithEmptyVersion(); - - // Act - var agent = client.AsAIAgent(agentRecord); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentVersion handles empty version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentVersionEmptyVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); - AgentVersion agentVersion = this.CreateTestAgentVersionWithEmptyVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that GetAIAgentAsync handles an agent with whitespace-only version by using "latest" as fallback. - /// - [Fact] - public async Task GetAIAgentAsync_WithWhitespaceVersion_CreatesAgentSuccessfullyAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - // Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentRecord handles whitespace-only version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentRecordWhitespaceVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); - AgentRecord agentRecord = this.CreateTestAgentRecordWithWhitespaceVersion(); - - // Act - var agent = client.AsAIAgent(agentRecord); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentVersion handles whitespace-only version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentVersionWhitespaceVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); - AgentVersion agentVersion = this.CreateTestAgentVersionWithWhitespaceVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - #endregion - - #region ApplyToolsToAgentDefinition Tests - - /// - /// Verify that CreateAIAgentAsync with non-PromptAgentDefinition and tools throws ArgumentException. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNonPromptAgentDefinitionAndTools_ThrowsArgumentExceptionAsync() - { - // Arrange - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - using HttpHandlerAssert httpHandler = new(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") - }); - -#pragma warning disable CA5399 - using HttpClient httpClient = new(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient client = new(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Create a mock AgentDefinition that is not PromptAgentDefinition - // Since we can't easily create a non-PromptAgentDefinition in the public API, we test this path via the CreateAIAgentAsync that builds a PromptAgentDefinition - // The ApplyToolsToAgentDefinition is only called when tools.Count > 0, and we provide tools - // But PromptAgentDefinition is always created by CreateAIAgentAsync(name, model, instructions, tools) - // So this path is hard to hit without mocking. Let's test the declarative function rejection instead. - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", JsonDocument.Parse("{}").RootElement); - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync( - name: "test-agent", - model: "test-model", - instructions: "Test", - tools: [declarativeFunction])); - - Assert.Contains("invokable AIFunctions", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with AIFunctionDeclaration tools throws InvalidOperationException. - /// - [Fact] - public async Task CreateAIAgentAsync_WithAIFunctionDeclarationTool_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - using var doc = JsonDocument.Parse("{}"); - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); - - using HttpHandlerAssert httpHandler = new(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") - }); - -#pragma warning disable CA5399 - using HttpClient httpClient = new(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient client = new(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync( - name: "test-agent", - model: "test-model", - instructions: "Test", - tools: [declarativeFunction])); - - Assert.Contains("invokable AIFunctions", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with ResponseTool converted via AsAITool works. - /// - [Fact] - public async Task CreateAIAgentAsync_WithResponseToolAsAITool_CreatesAgentSuccessfullyAsync() - { - // Arrange - ResponseTool responseTool = ResponseTool.CreateFunctionTool("response_tool", BinaryData.FromString("{}"), strictModeEnabled: false); - AITool convertedTool = responseTool.AsAITool(); - - // Create a definition with the function tool already in it - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(responseTool); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - // Matching invokable tool must be provided - var invokableTool = AIFunctionFactory.Create(() => "test", "response_tool", "Invokable version of the tool"); - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = [invokableTool] - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with hosted tool types works correctly. - /// - [Fact] - public async Task CreateAIAgentAsync_WithHostedToolTypes_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var webSearchTool = new HostedWebSearchTool(); - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = [webSearchTool] - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that when the server returns tools but matching tools are provided, the agent is created. - /// - [Fact] - public async Task GetAIAgentAsync_WithServerDefinedToolsAndMatchingProvidedTools_CreatesAgentAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - // Add multiple function tools - definition.Tools.Add(ResponseTool.CreateFunctionTool("tool_one", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add(ResponseTool.CreateFunctionTool("tool_two", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var tools = new List - { - AIFunctionFactory.Create(() => "one", "tool_one", "Tool one"), - AIFunctionFactory.Create(() => "two", "tool_two", "Tool two") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that when the server returns mixed tools (function and hosted), the agent handles them correctly. - /// - [Fact] - public async Task GetAIAgentAsync_WithMixedServerTools_MatchesFunctionToolsOnlyAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - // Add a function tool - definition.Tools.Add(ResponseTool.CreateFunctionTool("function_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - // Add a hosted tool - definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var tools = new List - { - AIFunctionFactory.Create(() => "result", "function_tool", "The function tool") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that when partial tools are provided (some missing), InvalidOperationException is thrown listing missing tools. - /// - [Fact] - public async Task GetAIAgentAsync_WithPartialToolsProvided_ThrowsInvalidOperationWithMissingToolNamesAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("provided_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add(ResponseTool.CreateFunctionTool("missing_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var tools = new List - { - // Only providing one of two required tools - AIFunctionFactory.Create(() => "result", "provided_tool", "The provided tool") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Contains("missing_tool", exception.Message); - Assert.DoesNotContain("provided_tool", exception.Message); - } - - /// - /// Verify that when AsAIAgent is called without requireInvocableTools, hosted tools are correctly added. - /// - [Fact] - public void AsAIAgent_WithServerHostedTools_AddsToolsToAgentOptions() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); - - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson(agentDefinition: definition)))!; - - // Act - no tools provided, but requireInvocableTools is false when no tools param is passed - FoundryAgent agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region Helper Methods - - /// - /// Creates a test AIProjectClient with fake behavior. - /// - private FakeAgentClient CreateTestAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse); - } - - /// - /// Creates a test AIProjectClient backed by an HTTP handler that returns canned responses. - /// Used for tests that exercise the protocol-method code path (CreateAgentVersion). - /// The returned client must be disposed to clean up the underlying HttpClient/handler. - /// - private static DisposableTestClient CreateTestAgentClientWithHandler(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - var responseJson = TestDataUtil.GetAgentVersionResponseJson(agentName, agentDefinitionResponse, instructions, description); - - var httpHandler = new HttpHandlerAssert(_ => - new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseJson, Encoding.UTF8, "application/json") }); - -#pragma warning disable CA5399 - var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - var client = new AIProjectClient( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - return new DisposableTestClient(client, httpClient, httpHandler); - } - - /// - /// Wraps an AIProjectClient and its disposable dependencies for deterministic cleanup. - /// - private sealed class DisposableTestClient : IDisposable - { - private readonly HttpClient _httpClient; - private readonly HttpHandlerAssert _httpHandler; - - public DisposableTestClient(AIProjectClient client, HttpClient httpClient, HttpHandlerAssert httpHandler) - { - this.Client = client; - this._httpClient = httpClient; - this._httpHandler = httpHandler; - } - - public AIProjectClient Client { get; } - - public void Dispose() - { - this._httpClient.Dispose(); - this._httpHandler.Dispose(); - } - } - - /// - /// Creates a test AgentRecord for testing. - /// - private AgentRecord CreateTestAgentRecord(AgentDefinition? agentDefinition = null) - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJson(agentDefinition: agentDefinition)))!; - } - - /// - /// Creates a test AIProjectClient with empty version fields for testing hosted MCP agents. - /// - private FakeAgentClient CreateTestAgentClientWithEmptyVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, useEmptyVersion: true); - } - - /// - /// Creates a test AgentRecord with empty version for testing hosted MCP agents. - /// - private AgentRecord CreateTestAgentRecordWithEmptyVersion(AgentDefinition? agentDefinition = null) - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithEmptyVersion(agentDefinition: agentDefinition)))!; - } - - /// - /// Creates a test AgentVersion with empty version for testing hosted MCP agents. - /// - private AgentVersion CreateTestAgentVersionWithEmptyVersion() - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion()))!; - } - - /// - /// Creates a test AIProjectClient with whitespace-only version fields for testing hosted MCP agents. - /// - private FakeAgentClient CreateTestAgentClientWithWhitespaceVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, versionMode: VersionMode.Whitespace); - } - - /// - /// Creates a test AgentRecord with whitespace-only version for testing hosted MCP agents. - /// - private AgentRecord CreateTestAgentRecordWithWhitespaceVersion(AgentDefinition? agentDefinition = null) - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(agentDefinition: agentDefinition)))!; - } - - /// - /// Creates a test AgentVersion with whitespace-only version for testing hosted MCP agents. - /// - private AgentVersion CreateTestAgentVersionWithWhitespaceVersion() - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion()))!; - } - - private const string OpenAPISpec = """ - { - "openapi": "3.0.3", - "info": { "title": "Tiny Test API", "version": "1.0.0" }, - "paths": { - "/ping": { - "get": { - "summary": "Health check", - "operationId": "getPing", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "message": { "type": "string" } }, - "required": ["message"] - }, - "example": { "message": "pong" } - } - } - } - } - } - } - } - } - """; - - /// - /// Creates a test AgentVersion for testing. - /// - private AgentVersion CreateTestAgentVersion() - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; - } - - /// - /// Specifies the version mode for test data generation. - /// - private enum VersionMode - { - Normal, - Empty, - Whitespace - } - - /// - /// Fake AIProjectClient for testing. - /// - private sealed class FakeAgentClient : AIProjectClient - { - public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, bool useEmptyVersion = false, VersionMode versionMode = VersionMode.Normal) - { - // Handle backward compatibility with bool parameter - var effectiveVersionMode = useEmptyVersion ? VersionMode.Empty : versionMode; - this.Agents = new FakeAgentsClient(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode); - } - - public override ClientConnection GetConnection(string connectionId) - { - return new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None); - } - - public override AgentsClient Agents { get; } - - private sealed class FakeAgentsClient : AgentsClient - { - private readonly string? _agentName; - private readonly string? _instructions; - private readonly string? _description; - private readonly AgentDefinition? _agentDefinition; - private readonly VersionMode _versionMode; - - public FakeAgentsClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal) - { - this._agentName = agentName; - this._instructions = instructions; - this._description = description; - this._agentDefinition = agentDefinitionResponse; - this._versionMode = versionMode; - } - - private string GetAgentResponseJson() - { - return this._versionMode switch - { - VersionMode.Empty => TestDataUtil.GetAgentResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - VersionMode.Whitespace => TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - _ => TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) - }; - } - - private string GetAgentVersionResponseJson() - { - return this._versionMode switch - { - VersionMode.Empty => TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - VersionMode.Whitespace => TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - _ => TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) - }; - } - - public override ClientResult GetAgent(string agentName, RequestOptions options) - { - var responseJson = this.GetAgentResponseJson(); - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); - } - - public override ClientResult GetAgent(string agentName, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentResponseJson(); - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); - } - - public override Task GetAgentAsync(string agentName, RequestOptions options) - { - var responseJson = this.GetAgentResponseJson(); - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); - } - - public override Task> GetAgentAsync(string agentName, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentResponseJson(); - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); - } - - public override ClientResult CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentVersionResponseJson(); - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); - } - - public override Task> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentVersionResponseJson(); - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); - } - } - } - - private static PromptAgentDefinition GeneratePromptDefinitionResponse(PromptAgentDefinition inputDefinition, List? tools) - { - var definitionResponse = new PromptAgentDefinition(inputDefinition.Model) { Instructions = inputDefinition.Instructions }; - if (tools is not null) - { - foreach (var tool in tools) - { - definitionResponse.Tools.Add(tool.GetService() ?? tool.AsOpenAIResponseTool()); - } - } - - return definitionResponse; - } - - /// - /// Test custom chat client that can be used to verify clientFactory functionality. - /// - private sealed class TestChatClient : DelegatingChatClient - { - public TestChatClient(IChatClient innerClient) : base(innerClient) - { - } - } - - /// - /// Mock pipeline response for testing ClientResult wrapping. - /// - private sealed class MockPipelineResponse : PipelineResponse - { - private readonly MockPipelineResponseHeaders _headers; - - public MockPipelineResponse(int status, BinaryData? content = null) - { - this.Status = status; - this.Content = content ?? BinaryData.Empty; - this._headers = new MockPipelineResponseHeaders(); - } - - public override int Status { get; } - - public override string ReasonPhrase => "OK"; - - public override Stream? ContentStream - { - get => null; - set { } - } - - public override BinaryData Content { get; } - - protected override PipelineResponseHeaders HeadersCore => this._headers; - - public override BinaryData BufferContent(CancellationToken cancellationToken = default) => - throw new NotSupportedException("Buffering content is not supported for mock responses."); - - public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException("Buffering content asynchronously is not supported for mock responses."); - - public override void Dispose() - { - } - - private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders - { - private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase) - { - { "Content-Type", "application/json" }, - { "x-ms-request-id", "test-request-id" } - }; - - public override bool TryGetValue(string name, out string? value) - { - return this._headers.TryGetValue(name, out value); - } - - public override bool TryGetValues(string name, out IEnumerable? values) - { - if (this._headers.TryGetValue(name, out var value)) - { - values = [value]; - return true; - } - - values = null; - return false; - } - - public override IEnumerator> GetEnumerator() - { - return this._headers.GetEnumerator(); - } - } - } - - #endregion - - /// - /// Helper method to access internal ChatOptions property via reflection. - /// - private static ChatOptions? GetAgentChatOptions(AIAgent agent) - { - ChatClientAgent? chatClientAgent = agent as ChatClientAgent ?? agent.GetService(); - if (chatClientAgent is null) - { - return null; - } - - var chatOptionsProperty = typeof(ChatClientAgent).GetProperty( - "ChatOptions", - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.NonPublic | - System.Reflection.BindingFlags.Instance); - - return chatOptionsProperty?.GetValue(chatClientAgent) as ChatOptions; - } - - /// - /// Test schema for JSON response format tests. - /// -#pragma warning disable CA1812 // Avoid uninstantiated internal classes - used via reflection by AIJsonUtilities - private sealed class TestSchema - { - public string? Name { get; set; } - public int Value { get; set; } - } -#pragma warning restore CA1812 - - /// - /// Test AIContextProvider for options preservation tests. - /// - private sealed class TestAIContextProvider : AIContextProvider - { - protected override ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - return new ValueTask(context.AIContext); - } - } - - /// - /// Test ChatHistoryProvider for options preservation tests. - /// - private sealed class TestChatHistoryProvider : ChatHistoryProvider - { - protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - return new ValueTask>(context.RequestMessages); - } - - protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default) - { - return default; - } - } -} -#pragma warning restore CS0618 - -/// -/// Provides test data for invalid agent name validation tests. -/// -internal static class InvalidAgentNameTestData -{ - /// - /// Gets a collection of invalid agent names for theory-based testing. - /// - /// Collection of invalid agent name test cases. - public static IEnumerable GetInvalidAgentNames() - { - yield return new object[] { "-agent" }; - yield return new object[] { "agent-" }; - yield return new object[] { "agent_name" }; - yield return new object[] { "agent name" }; - yield return new object[] { "agent@name" }; - yield return new object[] { "agent#name" }; - yield return new object[] { "agent$name" }; - yield return new object[] { "agent%name" }; - yield return new object[] { "agent&name" }; - yield return new object[] { "agent*name" }; - yield return new object[] { "agent.name" }; - yield return new object[] { "agent/name" }; - yield return new object[] { "agent\\name" }; - yield return new object[] { "agent:name" }; - yield return new object[] { "agent;name" }; - yield return new object[] { "agent,name" }; - yield return new object[] { "agentname" }; - yield return new object[] { "agent?name" }; - yield return new object[] { "agent!name" }; - yield return new object[] { "agent~name" }; - yield return new object[] { "agent`name" }; - yield return new object[] { "agent^name" }; - yield return new object[] { "agent|name" }; - yield return new object[] { "agent[name" }; - yield return new object[] { "agent]name" }; - yield return new object[] { "agent{name" }; - yield return new object[] { "agent}name" }; - yield return new object[] { "agent(name" }; - yield return new object[] { "agent)name" }; - yield return new object[] { "agent+name" }; - yield return new object[] { "agent=name" }; - yield return new object[] { "a" + new string('b', 63) }; - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs new file mode 100644 index 0000000000..e96cbdc487 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs @@ -0,0 +1,1814 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Moq; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +#pragma warning disable CS0618 +/// +/// Unit tests for the class. +/// +public sealed class AzureAIProjectChatClientExtensionsTests +{ + #region AsAIAgent(AIProjectClient, model, instructions) Tests + + /// + /// Verify that the non-versioned AsAIAgent overload throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithModelAndInstructions_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + client!.AsAIAgent("gpt-4o-mini", "You are helpful.")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that the non-versioned AsAIAgent overload creates a valid ChatClientAgent. + /// + [Fact] + public void AsAIAgent_Rapi_WithModelAndInstructions_CreatesChatClientAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + List tools = + [ + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + ]; + + // Act + ChatClientAgent agent = client.AsAIAgent( + "gpt-4o-mini", + "You are helpful.", + name: "test-agent", + description: "A test agent", + tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("A test agent", agent.Description); + Assert.NotNull(agent.GetService()); + Assert.Null(agent.GetService()); + } + + /// + /// Verify that the non-versioned AsAIAgent overload applies the clientFactory. + /// + [Fact] + public void AsAIAgent_WithModelAndInstructions_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + TestChatClient? testChatClient = null; + + // Act + ChatClientAgent agent = client.AsAIAgent( + "gpt-4o-mini", + "You are helpful.", + clientFactory: innerClient => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + TestChatClient? retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that the options-based non-versioned AsAIAgent overload creates a valid ChatClientAgent. + /// + [Fact] + public void AsAIAgent_Rapi_WithOptions_CreatesChatClientAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ChatClientAgentOptions options = new() + { + Name = "options-agent", + Description = "Agent from options", + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Instructions = "You are helpful.", + }, + }; + + // Act + ChatClientAgent agent = client.AsAIAgent(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("options-agent", agent.Name); + Assert.Equal("Agent from options", agent.Description); + Assert.Null(agent.GetService()); + } + + /// + /// Verify that the non-versioned AsAIAgent overload adds the MEAI user-agent header to Responses API requests. + /// + [Fact] + public async Task AsAIAgent_Rapi_WithModelAndInstructions_UserAgentHeaderAddedToResponsesRequestsAsync() + { + // Arrange + bool userAgentFound = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Headers.TryGetValues("User-Agent", out IEnumerable? values)) + { + foreach (string value in values) + { + if (value.Contains("MEAI")) + { + userAgentFound = true; + } + } + } + + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + }); + +#pragma warning disable CA5399 + using HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient aiProjectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + ChatClientAgent agent = aiProjectClient.AsAIAgent( + "gpt-4o-mini", + "You are helpful."); + + // Act + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + // Assert + Assert.True(userAgentFound, "MEAI user-agent header was not found in any request"); + } + + #endregion + + #region AsAIAgent(AIProjectClient, ProjectsAgentRecord) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent(agentRecord)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when agentRecord is null. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_WithNullAgentRecord_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((ProjectsAgentRecord)null!)); + + Assert.Equal("agentRecord", exception.ParamName); + } + + /// + /// Verify that AsAIAgent with ProjectsAgentRecord creates a valid agent. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("agent_abc123", agent.Name); + Assert.Same(client, agent.GetService()); + } + + /// + /// Verify that AsAIAgent with ProjectsAgentRecord and clientFactory applies the factory. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + #endregion + + #region AsAIAgent(AIProjectClient, ProjectsAgentVersion) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent(agentVersion)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when agentVersion is null. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithNullAgentVersion_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((ProjectsAgentVersion)null!)); + + Assert.Equal("agentVersion", exception.ParamName); + } + + /// + /// Verify that AsAIAgent with ProjectsAgentVersion creates a valid agent. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("agent_abc123", agent.Name); + Assert.Same(client, agent.GetService()); + } + + /// + /// Verify that AsAIAgent with ProjectsAgentVersion and clientFactory applies the factory. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.AsAIAgent( + agentVersion, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that AsAIAgent with requireInvocableTools=true enforces invocable tools. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsTrue_EnforcesInvocableTools() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.AsAIAgent(agentVersion, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that AsAIAgent with requireInvocableTools=false allows declarative functions. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsFalse_AllowsDeclarativeFunctions() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act - should not throw even without tools when requireInvocableTools is false + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region AsAIAgent(AIProjectClient, string) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_ByName_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent("test-agent")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when name is null. + /// + [Fact] + public void AsAIAgent_ByName_WithNullName_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((string)null!)); + + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentException when name is empty. + /// + [Fact] + public void AsAIAgent_ByName_WithEmptyName_ThrowsArgumentException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent(string.Empty)); + + Assert.Equal("name", exception.ParamName); + } + + #endregion + + #region AsAIAgent(AIProjectClient, ProjectsAgentRecord) with tools Tests + + /// + /// Verify that AsAIAgent with additional tools when the definition has no tools does not throw and results in an agent with no tools. + /// + [Fact] + public void AsAIAgent_WithAgentRecordAndAdditionalTools_WhenDefinitionHasNoTools_ShouldNotThrow() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.AsAIAgent(agentRecord, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var agentVersion = chatClient.GetService(); + Assert.NotNull(agentVersion); + var definition = Assert.IsType(agentVersion.Definition); + Assert.Empty(definition.Tools); + } + + /// + /// Verify that AsAIAgent with null tools works correctly. + /// + [Fact] + public void AsAIAgent_WithAgentRecordAndNullTools_WorksCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord, tools: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("agent_abc123", agent.Name); + } + + #endregion + + #region Tool Validation Tests + + /// + /// Verify that when providing AITools with AsAIAgent, any additional tool that doesn't match the tools in agent definition are ignored. + /// + [Fact] + public void AsAIAgent_AdditionalAITools_WhenNotInTheDefinitionAreIgnored() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentVersion = this.CreateTestAgentVersion(); + + // Manually add tools to the definition to simulate inline tools + if (agentVersion.Definition is DeclarativeAgentDefinition promptDef) + { + promptDef.Tools.Add(ResponseTool.CreateFunctionTool("inline_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + } + + var invocableInlineAITool = AIFunctionFactory.Create(() => "test", "inline_tool", "An invocable AIFunction for the inline function"); + var shouldBeIgnoredTool = AIFunctionFactory.Create(() => "test", "additional_tool", "An additional test function that should be ignored"); + + // Act & Assert + var agent = client.AsAIAgent(agentVersion, tools: [invocableInlineAITool, shouldBeIgnoredTool]); + Assert.NotNull(agent); + var version = agent.GetService(); + Assert.NotNull(version); + var definition = Assert.IsType(version.Definition); + Assert.NotEmpty(definition.Tools); + Assert.NotNull(GetAgentChatOptions(agent)); + Assert.NotNull(GetAgentChatOptions(agent)!.Tools); + Assert.Single(GetAgentChatOptions(agent)!.Tools!); + Assert.Equal("inline_tool", (definition.Tools.First() as FunctionTool)?.FunctionName); + } + + #endregion + + #region Inline Tools vs Parameter Tools Tests + + /// + /// Verify that tools passed as parameters are accepted by AsAIAgent. + /// + [Fact] + public void AsAIAgent_WithParameterTools_AcceptsTools() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + var tools = new List + { + AIFunctionFactory.Create(() => "tool1", "param_tool_1", "First parameter tool"), + AIFunctionFactory.Create(() => "tool2", "param_tool_2", "Second parameter tool") + }; + + // Act + var agent = client.AsAIAgent(agentRecord, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var agentVersion = chatClient.GetService(); + Assert.NotNull(agentVersion); + } + + #endregion + + #region Declarative Function Handling Tests + + /// + /// Verifies that CreateAIAgent uses tools from definition when they are ResponseTool instances, resulting in successful agent creation. + /// + [Fact] + public async Task CreateAIAgentAsync_WithResponseToolsInDefinition_CreatesAgentSuccessfullyAsync() + { + // Arrange + var definition = new DeclarativeAgentDefinition("test-model") { Instructions = "Test instructions" }; + + var fabricToolOptions = new FabricDataAgentToolOptions(); + fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); + + var sharepointOptions = new SharePointGroundingToolOptions(); + sharepointOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); + + var structuredOutputs = new StructuredOutputDefinition("name", "description", new Dictionary { ["schema"] = BinaryData.FromString(AIJsonUtilities.CreateJsonSchema(new { id = "test" }.GetType()).ToString()) }, false); + + // Add tools to the definition + definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateBingCustomSearchTool(new BingCustomSearchToolOptions([new BingCustomSearchConfiguration("connection-id", "instance-name")]))); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateBrowserAutomationTool(new BrowserAutomationToolOptions(new BrowserAutomationToolConnectionParameters("id")))); + definition.Tools.Add(ProjectsAgentTool.CreateA2ATool(new Uri("https://test-uri.microsoft.com"))); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateBingGroundingTool(new BingGroundingSearchToolOptions([new BingGroundingSearchConfiguration("connection-id")]))); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateMicrosoftFabricTool(fabricToolOptions)); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateOpenApiTool(new OpenApiFunctionDefinition("name", BinaryData.FromString(OpenAPISpec), new OpenAPIAnonymousAuthenticationDetails()))); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateSharepointTool(sharepointOptions)); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateStructuredOutputsTool(structuredOutputs)); + definition.Tools.Add((ResponseTool)ProjectsAgentTool.CreateAzureAISearchTool(new AzureAISearchToolOptions([new AzureAISearchToolIndex() { IndexName = "name" }]))); + + // Generate agent definition response with the tools + var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); + + using var testClient = CreateTestAgentClientWithHandler(agentDefinitionResponse: definitionResponse); + + var options = new ProjectsAgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.AgentAdministrationClient.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion2 = agent.GetService()!; + Assert.NotNull(agentVersion); + if (agentVersion2.Definition is DeclarativeAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Equal(10, promptDef.Tools.Count); + } + } + + /// + /// Verify that AsAIAgentAsync accepts FunctionTools from definition. + /// + [Fact] + public async Task AsAIAgent_WithFunctionToolsInDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + var functionTool = ResponseTool.CreateFunctionTool( + functionName: "get_user_name", + functionParameters: BinaryData.FromString("{}"), + strictModeEnabled: false, + functionDescription: "Gets the user's name, as used for friendly address." + ); + + var definition = new DeclarativeAgentDefinition("test-model") { Instructions = "Test" }; + definition.Tools.Add(functionTool); + + // Generate response with the declarative function + var definitionResponse = new DeclarativeAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(functionTool); + + using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new ProjectsAgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.AgentAdministrationClient.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that AsAIAgentAsync accepts declarative functions from definition. + /// + [Fact] + public async Task AsAIAgent_WithDeclarativeFunctionFromDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + using var testClient = CreateTestAgentClientWithHandler(); + var definition = new DeclarativeAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + var options = new ProjectsAgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.AgentAdministrationClient.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that AsAIAgentAsync accepts declarative functions from definition. + /// + [Fact] + public async Task AsAIAgent_WithDeclarativeFunctionInDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + var definition = new DeclarativeAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + // Generate response with the declarative function + var definitionResponse = new DeclarativeAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new ProjectsAgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.AgentAdministrationClient.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region AgentName Validation Tests + + /// + /// Verify that AsAIAgent throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void AsAIAgent_ByName_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent(invalidName)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that AsAIAgent with AgentReference throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void AsAIAgent_WithAgentReference_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + var agentReference = new AgentReference(invalidName, "1"); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent(agentReference)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + #endregion + + #region AzureAIChatClient Behavior Tests + + /// + /// Verify that the underlying chat client created by extension methods can be wrapped with clientFactory. + /// + [Fact] + public void AsAIAgent_WithClientFactory_WrapsUnderlyingChatClient() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + int factoryCallCount = 0; + + // Act + var agent = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => + { + factoryCallCount++; + return new TestChatClient(innerClient); + }); + + // Assert + Assert.NotNull(agent); + Assert.Equal(1, factoryCallCount); + var wrappedClient = agent.GetService(); + Assert.NotNull(wrappedClient); + } + + /// + /// Verify that multiple clientFactory calls create independent wrapped clients. + /// + [Fact] + public void AsAIAgent_MultipleCallsWithClientFactory_CreatesIndependentClients() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent1 = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + var agent2 = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + var client1 = agent1.GetService(); + var client2 = agent2.GetService(); + Assert.NotNull(client1); + Assert.NotNull(client2); + Assert.NotSame(client1, client2); + } + + #endregion + + #region User-Agent Header Tests + + /// + /// Verifies that the MEAI user-agent header is added to Responses API POST requests + /// via the protocol method's RequestOptions pipeline policy. + /// + [Fact] + public async Task AsAIAgent_Rapi_UserAgentHeaderAddedToRequestsAsync() + { + bool userAgentFound = false; + using var httpHandler = new HttpHandlerAssert(request => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + // Verify MEAI user-agent header is present on Responses API POST request + if (request.Headers.TryGetValues("User-Agent", out var userAgentValues) + && userAgentValues.Any(v => v.Contains("MEAI"))) + { + userAgentFound = true; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agentOptions = new ChatClientAgentOptions + { + Name = "test-agent", + ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini" } + }; + + // Act + var agent = aiProjectClient.AsAIAgent(agentOptions); + + var response = await agent.RunAsync("Hello"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(response); + Assert.True(userAgentFound, "MEAI user-agent header was not found in any Responses API request"); + } + + /// + /// Verifies that the MEAI user-agent header is added to Responses API POST requests + /// when using a versioned agent created via CreateAgentVersionAsync. + /// + [Fact] + public async Task AsAIAgent_Versioned_UserAgentHeaderAddedToRequestsAsync() + { + bool userAgentFound = false; + using var httpHandler = new HttpHandlerAssert(request => + { + Assert.Equal("POST", request.Method.Method); + + if (request.RequestUri!.PathAndQuery.Contains("/responses")) + { + // Verify MEAI user-agent header is present on Responses API POST request + Assert.True(request.Headers.TryGetValues("User-Agent", out var userAgentValues)); + Assert.Contains(userAgentValues, v => v.Contains("MEAI")); + userAgentFound = true; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + // CreateAgentVersion POST — return agent version response + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agentVersion = (await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync("test-agent", new ProjectsAgentVersionCreationOptions(new DeclarativeAgentDefinition("test-model") { Instructions = "Test instructions" }))).Value; + + // Act + var agent = aiProjectClient.AsAIAgent(agentVersion); + + var response = await agent.RunAsync("Hello"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(response); + Assert.True(userAgentFound, "MEAI user-agent header was not found in any Responses API request"); + } + + #endregion + + #region GetAIAgent(AIProjectClient, AgentReference) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + var agentReference = new AgentReference("test-name", "1"); + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent(agentReference)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when agentReference is null. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithNullAgentReference_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((AgentReference)null!)); + + Assert.Equal("agentReference", exception.ParamName); + } + + /// + /// Verify that AsAIAgent with AgentReference creates a valid agent. + /// + [Fact] + public void AsAIAgent_WithAgentReference_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.AsAIAgent(agentReference); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("test-name", agent.Name); + Assert.Equal("test-name:1", agent.Id); + Assert.Same(client, agent.GetService()); + } + + /// + /// Verify that AsAIAgent with AgentReference and clientFactory applies the factory. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + TestChatClient? testChatClient = null; + + // Act + var agent = client.AsAIAgent( + agentReference, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that AsAIAgent with AgentReference sets the agent ID correctly. + /// + [Fact] + public void AsAIAgent_WithAgentReference_SetsAgentIdCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "2"); + + // Act + var agent = client.AsAIAgent(agentReference); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-name:2", agent.Id); + } + + /// + /// Verify that AsAIAgent with AgentReference and tools includes the tools in ChatOptions. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithTools_IncludesToolsInChatOptions() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.AsAIAgent(agentReference, tools: tools); + + // Assert + Assert.NotNull(agent); + var chatOptions = GetAgentChatOptions(agent); + Assert.NotNull(chatOptions); + Assert.NotNull(chatOptions.Tools); + Assert.Single(chatOptions.Tools); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService returns ProjectsAgentRecord for agents created from ProjectsAgentRecord. + /// + [Fact] + public void GetService_WithAgentRecord_ReturnsAgentRecord() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + var retrievedRecord = agent.GetService(); + + // Assert + Assert.NotNull(retrievedRecord); + Assert.Equal(agentRecord.Id, retrievedRecord.Id); + } + + /// + /// Verify that GetService returns null for ProjectsAgentRecord when agent is created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsNullForAgentRecord() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedRecord = agent.GetService(); + + // Assert + Assert.Null(retrievedRecord); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService returns ProjectsAgentVersion for agents created from ProjectsAgentVersion. + /// + [Fact] + public void GetService_WithAgentVersion_ReturnsAgentVersion() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + var retrievedVersion = agent.GetService(); + + // Assert + Assert.NotNull(retrievedVersion); + Assert.Equal(agentVersion.Id, retrievedVersion.Id); + } + + /// + /// Verify that GetService returns null for ProjectsAgentVersion when agent is created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsNullForAgentVersion() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedVersion = agent.GetService(); + + // Assert + Assert.Null(retrievedVersion); + } + + #endregion + + #region ChatClientMetadata Tests + + /// + /// Verify that ChatClientMetadata is properly populated for agents created from ProjectsAgentRecord. + /// + [Fact] + public void ChatClientMetadata_WithAgentRecord_IsPopulatedCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + Assert.NotNull(metadata.DefaultModelId); + } + + /// + /// Verify that ChatClientMetadata.DefaultModelId is set from DeclarativeAgentDefinition model property. + /// + [Fact] + public void ChatClientMetadata_WithDeclarativeAgentDefinition_SetsDefaultModelIdFromModel() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new DeclarativeAgentDefinition("gpt-4-turbo") + { + Instructions = "Test instructions" + }; + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(definition); + + // Act + var agent = client.AsAIAgent(agentRecord); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + // The metadata should contain the model information from the agent definition + Assert.NotNull(metadata.DefaultModelId); + Assert.Equal("gpt-4-turbo", metadata.DefaultModelId); + } + + /// + /// Verify that ChatClientMetadata is properly populated for agents created from ProjectsAgentVersion. + /// + [Fact] + public void ChatClientMetadata_WithAgentVersion_IsPopulatedCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + Assert.NotNull(metadata.DefaultModelId); + Assert.Equal((agentVersion.Definition as DeclarativeAgentDefinition)!.Model, metadata.DefaultModelId); + } + + #endregion + + #region AgentReference Availability Tests + + /// + /// Verify that GetService returns AgentReference for agents created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-agent", "1.0"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal("test-agent", retrievedReference.Name); + Assert.Equal("1.0", retrievedReference.Version); + } + + /// + /// Verify that GetService returns null for AgentReference when agent is created from ProjectsAgentRecord. + /// + [Fact] + public void GetService_WithAgentRecord_ReturnsAlsoAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal(agentRecord.Name, retrievedReference.Name); + } + + /// + /// Verify that GetService returns null for AgentReference when agent is created from ProjectsAgentVersion. + /// + [Fact] + public void GetService_WithAgentVersion_ReturnsAlsoAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal(agentVersion.Name, retrievedReference.Name); + } + + /// + /// Verify that GetService returns AgentReference with correct version information. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsCorrectVersionInformation() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("versioned-agent", "3.5"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal("versioned-agent", retrievedReference.Name); + Assert.Equal("3.5", retrievedReference.Version); + } + + #endregion + + #region Empty Version and ID Handling Tests + + /// + /// Verify that AsAIAgent with ProjectsAgentRecord handles empty version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentRecordEmptyVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecordWithEmptyVersion(); + + // Act + var agent = client.AsAIAgent(agentRecord); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + /// + /// Verify that AsAIAgent with ProjectsAgentVersion handles empty version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentVersionEmptyVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersionWithEmptyVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + /// + /// Verify that AsAIAgent with ProjectsAgentRecord handles whitespace-only version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentRecordWhitespaceVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); + ProjectsAgentRecord agentRecord = this.CreateTestAgentRecordWithWhitespaceVersion(); + + // Act + var agent = client.AsAIAgent(agentRecord); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + /// + /// Verify that AsAIAgent with ProjectsAgentVersion handles whitespace-only version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentVersionWhitespaceVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); + ProjectsAgentVersion agentVersion = this.CreateTestAgentVersionWithWhitespaceVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + #endregion + + #region ApplyToolsToAgentDefinition Tests + + /// + /// Verify that when AsAIAgent is called without requireInvocableTools, hosted tools are correctly added. + /// + [Fact] + public void AsAIAgent_WithServerHostedTools_AddsToolsToAgentOptions() + { + // Arrange + DeclarativeAgentDefinition definition = new("test-model") { Instructions = "Test" }; + definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); + + AIProjectClient client = this.CreateTestAgentClient(); + ProjectsAgentVersion agentVersion = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson(agentDefinition: definition)))!; + + // Act - no tools provided, but requireInvocableTools is false when no tools param is passed + FoundryAgent agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a test AIProjectClient with fake behavior. + /// + private FakeAgentClient CreateTestAgentClient(string? agentName = null, string? instructions = null, string? description = null, ProjectsAgentDefinition? agentDefinitionResponse = null) + { + return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse); + } + + /// + /// Creates a test AIProjectClient backed by an HTTP handler that returns canned responses. + /// Used for tests that exercise the protocol-method code path (CreateAgentVersion). + /// The returned client must be disposed to clean up the underlying HttpClient/handler. + /// + private static DisposableTestClient CreateTestAgentClientWithHandler(string? agentName = null, string? instructions = null, string? description = null, ProjectsAgentDefinition? agentDefinitionResponse = null) + { + var responseJson = TestDataUtil.GetAgentVersionResponseJson(agentName, agentDefinitionResponse, instructions, description); + + var httpHandler = new HttpHandlerAssert(_ => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseJson, Encoding.UTF8, "application/json") }); + +#pragma warning disable CA5399 + var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + return new DisposableTestClient(client, httpClient, httpHandler); + } + + /// + /// Wraps an AIProjectClient and its disposable dependencies for deterministic cleanup. + /// + private sealed class DisposableTestClient : IDisposable + { + private readonly HttpClient _httpClient; + private readonly HttpHandlerAssert _httpHandler; + + public DisposableTestClient(AIProjectClient client, HttpClient httpClient, HttpHandlerAssert httpHandler) + { + this.Client = client; + this._httpClient = httpClient; + this._httpHandler = httpHandler; + } + + public AIProjectClient Client { get; } + + public void Dispose() + { + this._httpClient.Dispose(); + this._httpHandler.Dispose(); + } + } + + /// + /// Creates a test ProjectsAgentRecord for testing. + /// + private ProjectsAgentRecord CreateTestAgentRecord(ProjectsAgentDefinition? agentDefinition = null) + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJson(agentDefinition: agentDefinition)))!; + } + + /// + /// Creates a test AIProjectClient with empty version fields for testing hosted MCP agents. + /// + private FakeAgentClient CreateTestAgentClientWithEmptyVersion(string? agentName = null, string? instructions = null, string? description = null, ProjectsAgentDefinition? agentDefinitionResponse = null) + { + return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, useEmptyVersion: true); + } + + /// + /// Creates a test ProjectsAgentRecord with empty version for testing hosted MCP agents. + /// + private ProjectsAgentRecord CreateTestAgentRecordWithEmptyVersion(ProjectsAgentDefinition? agentDefinition = null) + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithEmptyVersion(agentDefinition: agentDefinition)))!; + } + + /// + /// Creates a test ProjectsAgentVersion with empty version for testing hosted MCP agents. + /// + private ProjectsAgentVersion CreateTestAgentVersionWithEmptyVersion() + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion()))!; + } + + /// + /// Creates a test AIProjectClient with whitespace-only version fields for testing hosted MCP agents. + /// + private FakeAgentClient CreateTestAgentClientWithWhitespaceVersion(string? agentName = null, string? instructions = null, string? description = null, ProjectsAgentDefinition? agentDefinitionResponse = null) + { + return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, versionMode: VersionMode.Whitespace); + } + + /// + /// Creates a test ProjectsAgentRecord with whitespace-only version for testing hosted MCP agents. + /// + private ProjectsAgentRecord CreateTestAgentRecordWithWhitespaceVersion(ProjectsAgentDefinition? agentDefinition = null) + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(agentDefinition: agentDefinition)))!; + } + + /// + /// Creates a test ProjectsAgentVersion with whitespace-only version for testing hosted MCP agents. + /// + private ProjectsAgentVersion CreateTestAgentVersionWithWhitespaceVersion() + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion()))!; + } + + private const string OpenAPISpec = """ + { + "openapi": "3.0.3", + "info": { "title": "Tiny Test API", "version": "1.0.0" }, + "paths": { + "/ping": { + "get": { + "summary": "Health check", + "operationId": "getPing", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }, + "example": { "message": "pong" } + } + } + } + } + } + } + } + } + """; + + /// + /// Creates a test ProjectsAgentVersion for testing. + /// + private ProjectsAgentVersion CreateTestAgentVersion() + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; + } + + /// + /// Specifies the version mode for test data generation. + /// + private enum VersionMode + { + Normal, + Empty, + Whitespace + } + + /// + /// Fake AIProjectClient for testing. + /// + private sealed class FakeAgentClient : AIProjectClient + { + public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, ProjectsAgentDefinition? agentDefinitionResponse = null, bool useEmptyVersion = false, VersionMode versionMode = VersionMode.Normal) + { + // Handle backward compatibility with bool parameter + var effectiveVersionMode = useEmptyVersion ? VersionMode.Empty : versionMode; + this.AgentAdministrationClient = new FakeAgentsClient(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode); + } + + public override ClientConnection GetConnection(string connectionId) + { + return new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None); + } + + public override AgentAdministrationClient AgentAdministrationClient { get; } + + private sealed class FakeAgentsClient : AgentAdministrationClient + { + private readonly string? _agentName; + private readonly string? _instructions; + private readonly string? _description; + private readonly ProjectsAgentDefinition? _agentDefinition; + private readonly VersionMode _versionMode; + + public FakeAgentsClient(string? agentName = null, string? instructions = null, string? description = null, ProjectsAgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal) + { + this._agentName = agentName; + this._instructions = instructions; + this._description = description; + this._agentDefinition = agentDefinitionResponse; + this._versionMode = versionMode; + } + + private string GetAgentResponseJson() + { + return this._versionMode switch + { + VersionMode.Empty => TestDataUtil.GetAgentResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + VersionMode.Whitespace => TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + _ => TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) + }; + } + + private string GetAgentVersionResponseJson() + { + return this._versionMode switch + { + VersionMode.Empty => TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + VersionMode.Whitespace => TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + _ => TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) + }; + } + + public override ClientResult GetAgent(string agentName, RequestOptions options) + { + var responseJson = this.GetAgentResponseJson(); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); + } + + public override ClientResult GetAgent(string agentName, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentResponseJson(); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); + } + + public override Task GetAgentAsync(string agentName, RequestOptions options) + { + var responseJson = this.GetAgentResponseJson(); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); + } + + public override Task> GetAgentAsync(string agentName, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentResponseJson(); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); + } + + public override ClientResult CreateAgentVersion(string agentName, ProjectsAgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentVersionResponseJson(); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); + } + + public override Task> CreateAgentVersionAsync(string agentName, ProjectsAgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentVersionResponseJson(); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); + } + } + } + + private static DeclarativeAgentDefinition GeneratePromptDefinitionResponse(DeclarativeAgentDefinition inputDefinition, List? tools) + { + var definitionResponse = new DeclarativeAgentDefinition(inputDefinition.Model) { Instructions = inputDefinition.Instructions }; + if (tools is not null) + { + foreach (var tool in tools) + { + definitionResponse.Tools.Add(tool.GetService() ?? tool.AsOpenAIResponseTool()); + } + } + + return definitionResponse; + } + + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : DelegatingChatClient + { + public TestChatClient(IChatClient innerClient) : base(innerClient) + { + } + } + + /// + /// Mock pipeline response for testing ClientResult wrapping. + /// + private sealed class MockPipelineResponse : PipelineResponse + { + private readonly MockPipelineResponseHeaders _headers; + + public MockPipelineResponse(int status, BinaryData? content = null) + { + this.Status = status; + this.Content = content ?? BinaryData.Empty; + this._headers = new MockPipelineResponseHeaders(); + } + + public override int Status { get; } + + public override string ReasonPhrase => "OK"; + + public override Stream? ContentStream + { + get => null; + set { } + } + + public override BinaryData Content { get; } + + protected override PipelineResponseHeaders HeadersCore => this._headers; + + public override BinaryData BufferContent(CancellationToken cancellationToken = default) => + throw new NotSupportedException("Buffering content is not supported for mock responses."); + + public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException("Buffering content asynchronously is not supported for mock responses."); + + public override void Dispose() + { + } + + private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders + { + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase) + { + { "Content-Type", "application/json" }, + { "x-ms-request-id", "test-request-id" } + }; + + public override bool TryGetValue(string name, out string? value) + { + return this._headers.TryGetValue(name, out value); + } + + public override bool TryGetValues(string name, out IEnumerable? values) + { + if (this._headers.TryGetValue(name, out var value)) + { + values = [value]; + return true; + } + + values = null; + return false; + } + + public override IEnumerator> GetEnumerator() + { + return this._headers.GetEnumerator(); + } + } + } + + #endregion + + /// + /// Helper method to access internal ChatOptions property via reflection. + /// + private static ChatOptions? GetAgentChatOptions(AIAgent agent) + { + ChatClientAgent? chatClientAgent = agent as ChatClientAgent ?? agent.GetService(); + if (chatClientAgent is null) + { + return null; + } + + var chatOptionsProperty = typeof(ChatClientAgent).GetProperty( + "ChatOptions", + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + + return chatOptionsProperty?.GetValue(chatClientAgent) as ChatOptions; + } + + /// + /// Test schema for JSON response format tests. + /// +#pragma warning disable CA1812 // Avoid uninstantiated internal classes - used via reflection by AIJsonUtilities + private sealed class TestSchema + { + public string? Name { get; set; } + public int Value { get; set; } + } +#pragma warning restore CA1812 +#pragma warning restore CS0618 + +} + +/// +/// Provides test data for invalid agent name validation tests. +/// +internal static class InvalidAgentNameTestData +{ + /// + /// Gets a collection of invalid agent names for theory-based testing. + /// + /// Collection of invalid agent name test cases. + public static IEnumerable GetInvalidAgentNames() + { + yield return new object[] { "-agent" }; + yield return new object[] { "agent-" }; + yield return new object[] { "agent_name" }; + yield return new object[] { "agent name" }; + yield return new object[] { "agent@name" }; + yield return new object[] { "agent#name" }; + yield return new object[] { "agent$name" }; + yield return new object[] { "agent%name" }; + yield return new object[] { "agent&name" }; + yield return new object[] { "agent*name" }; + yield return new object[] { "agent.name" }; + yield return new object[] { "agent/name" }; + yield return new object[] { "agent\\name" }; + yield return new object[] { "agent:name" }; + yield return new object[] { "agent;name" }; + yield return new object[] { "agent,name" }; + yield return new object[] { "agentname" }; + yield return new object[] { "agent?name" }; + yield return new object[] { "agent!name" }; + yield return new object[] { "agent~name" }; + yield return new object[] { "agent`name" }; + yield return new object[] { "agent^name" }; + yield return new object[] { "agent|name" }; + yield return new object[] { "agent[name" }; + yield return new object[] { "agent]name" }; + yield return new object[] { "agent{name" }; + yield return new object[] { "agent}name" }; + yield return new object[] { "agent(name" }; + yield return new object[] { "agent)name" }; + yield return new object[] { "agent+name" }; + yield return new object[] { "agent=name" }; + yield return new object[] { "a" + new string('b', 63) }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs similarity index 82% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs index 8582ccc2b6..e3461d8191 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs @@ -6,33 +6,35 @@ using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; #pragma warning disable CS0618 -[Obsolete("Uses obsolete AIProjectClient.GetAIAgentAsync compatibility extensions while validating chat-client behavior.")] public class AzureAIProjectChatClientTests { /// - /// Verify that when the ChatOptions has a "conv_" prefixed conversation ID, the chat client uses conversation in the http requests via the chat client + /// Verify that after the first RunAsync, the session's ConversationId is set from the + /// response, and subsequent requests include that conversation ID automatically. /// [Fact] public async Task ChatClient_UsesDefaultConversationIdAsync() { // Arrange - var requestTriggered = false; + var responsesRequestCount = 0; using var httpHandler = new HttpHandlerAssert(async (request) => { if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) { - requestTriggered = true; + responsesRequestCount++; - // Assert - if (request.Content is not null) + // Assert: On the second Responses API call, verify the conversation ID + // from the first response is automatically included in the request body. + if (responsesRequestCount == 2 && request.Content is not null) { var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.Contains("conv_12345", requestBody); + Assert.Contains("resp_0888a", requestBody); } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; @@ -50,20 +52,17 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_12345" } - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); await agent.RunAsync("Hello", session); + await agent.RunAsync("Follow up", session); - Assert.True(requestTriggered); + // Assert + Assert.Equal(2, responsesRequestCount); var chatClientSession = Assert.IsType(session); - Assert.Equal("conv_12345", chatClientSession.ConversationId); + Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); } /// @@ -102,12 +101,7 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" }, - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); @@ -154,12 +148,7 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_should_not_use_default" } - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); @@ -206,12 +195,7 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" }, - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FakeAuthenticationTokenProvider.cs similarity index 95% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FakeAuthenticationTokenProvider.cs index d37ed881ff..594ed85af3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FakeAuthenticationTokenProvider.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider { diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 1b77e8f57e..1ddc8c7c82 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Azure.AI.Projects; using Microsoft.Extensions.AI; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Unit tests for the class. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HttpHandlerAssert.cs similarity index 96% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HttpHandlerAssert.cs index 3b8025ed9e..0febf216b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HttpHandlerAssert.cs @@ -5,7 +5,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; internal sealed class HttpHandlerAssert : HttpClientHandler { diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs similarity index 98% rename from dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs index 226596a374..b1696d3162 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs @@ -2,7 +2,7 @@ using System; -namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory; /// /// Tests for constructor validation. diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs index 25c041f754..f1c4c75718 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs @@ -11,7 +11,7 @@ using System.Threading.Tasks; using Azure.AI.Projects; using Azure.Core; -namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory; /// /// Creates a testable AIProjectClient with a mock HTTP handler. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj similarity index 65% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 193a7d47da..7b85de0384 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -1,7 +1,12 @@ - + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs index d10ef861c9..fda9962e12 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs @@ -6,7 +6,7 @@ using Azure.AI.Extensions.OpenAI; using Microsoft.Extensions.AI; using OpenAI.Responses; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Unit tests for the class. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentResponse.json similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentResponse.json diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentVersionResponse.json similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentVersionResponse.json diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/OpenAIDefaultResponse.json similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/OpenAIDefaultResponse.json diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs similarity index 87% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs index 9cd2ecea46..3460362efd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs @@ -4,7 +4,7 @@ using System.ClientModel.Primitives; using System.IO; using Azure.AI.Projects.Agents; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Utility class for loading and processing test data files. @@ -29,7 +29,7 @@ internal static class TestDataUtil /// /// Gets the agent response JSON with optional placeholder replacements applied. /// - public static string GetAgentResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + public static string GetAgentResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) { var json = s_agentResponseJson; json = ApplyAgentName(json, agentName); @@ -42,7 +42,7 @@ internal static class TestDataUtil /// /// Gets the agent version response JSON with optional placeholder replacements applied. /// - public static string GetAgentVersionResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + public static string GetAgentVersionResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) { var json = s_agentVersionResponseJson; json = ApplyAgentName(json, agentName); @@ -55,7 +55,7 @@ internal static class TestDataUtil /// /// Gets the agent version response JSON with empty version and ID fields for testing hosted agents like MCP agents. /// - public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) { var json = s_agentVersionResponseJson; json = ApplyAgentName(json, agentName); @@ -71,7 +71,7 @@ internal static class TestDataUtil /// /// Gets the agent response JSON with empty version and ID fields in the latest version for testing hosted agents like MCP agents. /// - public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) { var json = s_agentResponseJson; json = ApplyAgentName(json, agentName); @@ -87,7 +87,7 @@ internal static class TestDataUtil /// /// Gets the agent version response JSON with whitespace-only version and ID fields for testing hosted agents like MCP agents. /// - public static string GetAgentVersionResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + public static string GetAgentVersionResponseJsonWithWhitespaceVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) { var json = s_agentVersionResponseJson; json = ApplyAgentName(json, agentName); @@ -103,7 +103,7 @@ internal static class TestDataUtil /// /// Gets the agent response JSON with whitespace-only version and ID fields in the latest version for testing hosted agents like MCP agents. /// - public static string GetAgentResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + public static string GetAgentResponseJsonWithWhitespaceVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) { var json = s_agentResponseJson; json = ApplyAgentName(json, agentName); @@ -119,7 +119,7 @@ internal static class TestDataUtil /// /// Gets the OpenAI default response JSON with optional placeholder replacements applied. /// - public static string GetOpenAIDefaultResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) + public static string GetOpenAIDefaultResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null) { var json = s_openAIDefaultResponseJson; json = ApplyAgentName(json, agentName); @@ -138,7 +138,7 @@ internal static class TestDataUtil return json; } - private static string ApplyAgentDefinition(string json, AgentDefinition? definition) + private static string ApplyAgentDefinition(string json, ProjectsAgentDefinition? definition) { return (definition is not null) ? json.Replace(AgentDefinitionPlaceholder, ModelReaderWriter.Write(definition).ToString()) diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj deleted file mode 100644 index af184142ca..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - True - True - - - - - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj deleted file mode 100644 index 1fe8dc57bd..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - false - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs deleted file mode 100644 index 44a4b73b52..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs +++ /dev/null @@ -1,1013 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable CS0618 // Type or member is obsolete - This is intentional as we are testing deprecated methods - -using System; -using System.ClientModel; -using System.ClientModel.Primitives; -using System.IO; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using OpenAI.Assistants; - -namespace Microsoft.Agents.AI.OpenAI.UnitTests.Extensions; - -/// -/// Unit tests for the class. -/// -public sealed class OpenAIAssistantClientExtensionsTests -{ - /// - /// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model")); - const string ModelId = "test-model"; - - // Act - var agent = await assistantClient.CreateAIAgentAsync( - ModelId, - instructions: "Test instructions", - name: "Test Agent", - description: "Test description", - clientFactory: (innerClient) => testChatClient); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - Assert.Equal("Test description", agent.Description); - - // Verify that the custom chat client can be retrieved from the agent's service collection - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectlyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - TestChatClient? testChatClient = null; - - const string ModelId = "test-model"; - - // Act - var agent = await assistantClient.CreateAIAgentAsync( - ModelId, - instructions: "Test instructions", - clientFactory: (innerClient) => - innerClient.AsBuilder() - .Use((innerClient) => testChatClient = new TestChatClient(innerClient)) - .Build()); - - // Assert - Assert.NotNull(agent); - - // Verify that the custom chat client can be retrieved from the agent's service collection - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory. - /// - [Fact] - public async Task CreateAIAgentAsync_WithOptionsAndClientFactory_AppliesFactoryCorrectlyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model")); - const string ModelId = "test-model"; - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - Description = "Test description", - ChatOptions = new() { Instructions = "Test instructions" } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync( - ModelId, - options, - clientFactory: (innerClient) => testChatClient); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - Assert.Equal("Test description", agent.Description); - - // Verify that the custom chat client can be retrieved from the agent's service collection - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that CreateAIAgent without clientFactory works normally. - /// - [Fact] - public async Task CreateAIAgentAsync_WithoutClientFactory_WorksNormallyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - - // Act - var agent = await assistantClient.CreateAIAgentAsync( - ModelId, - instructions: "Test instructions", - name: "Test Agent"); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - - // Verify that no TestChatClient is available since no factory was provided - var retrievedTestClient = agent.GetService(); - Assert.Null(retrievedTestClient); - } - - /// - /// Verify that CreateAIAgent with null clientFactory works normally. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNullClientFactory_WorksNormallyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - - // Act - var agent = await assistantClient.CreateAIAgentAsync( - ModelId, - instructions: "Test instructions", - name: "Test Agent", - clientFactory: null); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - - // Verify that no TestChatClient is available since no factory was provided - var retrievedTestClient = agent.GetService(); - Assert.Null(retrievedTestClient); - } - - /// - /// Verify that CreateAIAgent throws ArgumentNullException when client is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync() - { - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - ((AssistantClient)null!).CreateAIAgentAsync("test-model")); - - Assert.Equal("client", exception.ParamName); - } - - /// - /// Verify that CreateAIAgent throws ArgumentNullException when model is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNullModel_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - assistantClient.CreateAIAgentAsync(null!)); - - Assert.Equal("model", exception.ParamName); - } - - /// - /// Verify that CreateAIAgent with options throws ArgumentNullException when options is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNullOptions_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - assistantClient.CreateAIAgentAsync("test-model", (ChatClientAgentOptions)null!)); - - Assert.Equal("options", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with ClientResult and options works correctly. - /// - [Fact] - public void AsAIAgent_WithClientResultAndOptions_WorksCorrectly() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; - var clientResult = ClientResult.FromValue(assistant, new FakePipelineResponse()); - - var options = new ChatClientAgentOptions - { - Name = "Override Name", - Description = "Override Description", - ChatOptions = new() { Instructions = "Override Instructions" } - }; - - // Act - var agent = assistantClient.AsAIAgent(clientResult, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Override Name", agent.Name); - Assert.Equal("Override Description", agent.Description); - Assert.Equal("Override Instructions", agent.Instructions); - } - - /// - /// Verify that AsAIAgent with Assistant and options works correctly. - /// - [Fact] - public void AsAIAgent_WithAssistantAndOptions_WorksCorrectly() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; - - var options = new ChatClientAgentOptions - { - Name = "Override Name", - Description = "Override Description", - ChatOptions = new() { Instructions = "Override Instructions" } - }; - - // Act - var agent = assistantClient.AsAIAgent(assistant, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Override Name", agent.Name); - Assert.Equal("Override Description", agent.Description); - Assert.Equal("Override Instructions", agent.Instructions); - } - - /// - /// Verify that AsAIAgent with Assistant and options falls back to assistant metadata when options are null. - /// - [Fact] - public void AsAIAgent_WithAssistantAndOptionsWithNullFields_FallsBackToAssistantMetadata() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; - - var options = new ChatClientAgentOptions(); // Empty options - - // Act - var agent = assistantClient.AsAIAgent(assistant, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Original Name", agent.Name); - Assert.Equal("Original Description", agent.Description); - Assert.Equal("Original Instructions", agent.Instructions); - } - - /// - /// Verify that GetAIAgentAsync with agentId and options works correctly. - /// - [Fact] - public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string AgentId = "asst_abc123"; - - var options = new ChatClientAgentOptions - { - Name = "Override Name", - Description = "Override Description", - ChatOptions = new() { Instructions = "Override Instructions" } - }; - - // Act - var agent = await assistantClient.GetAIAgentAsync(AgentId, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Override Name", agent.Name); - Assert.Equal("Override Description", agent.Description); - Assert.Equal("Override Instructions", agent.Instructions); - } - - /// - /// Verify that AsAIAgent with clientFactory parameter correctly applies the factory. - /// - [Fact] - public void AsAIAgent_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent"}"""))!; - var testChatClient = new TestChatClient(assistantClient.AsIChatClient("asst_abc123")); - - var options = new ChatClientAgentOptions - { - Name = "Test Agent" - }; - - // Act - var agent = assistantClient.AsAIAgent( - assistant, - options, - clientFactory: (innerClient) => testChatClient); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - - // Verify that the custom chat client can be retrieved from the agent's service collection - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when assistantClientResult is null. - /// - [Fact] - public void AsAIAgent_WithNullClientResult_ThrowsArgumentNullException() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var options = new ChatClientAgentOptions(); - - // Act & Assert - var exception = Assert.Throws(() => - assistantClient.AsAIAgent(null!, options)); - - Assert.Equal("assistantClientResult", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when assistant is null. - /// - [Fact] - public void AsAIAgent_WithNullAssistant_ThrowsArgumentNullException() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var options = new ChatClientAgentOptions(); - - // Act & Assert - var exception = Assert.Throws(() => - assistantClient.AsAIAgent((Assistant)null!, options)); - - Assert.Equal("assistantMetadata", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when options is null. - /// - [Fact] - public void AsAIAgent_WithNullOptions_ThrowsArgumentNullException() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}"""))!; - - // Act & Assert - var exception = Assert.Throws(() => - assistantClient.AsAIAgent(assistant, (ChatClientAgentOptions)null!)); - - Assert.Equal("options", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty. - /// - [Fact] - public async Task GetAIAgentAsync_WithEmptyAgentId_ThrowsArgumentExceptionAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var options = new ChatClientAgentOptions(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - assistantClient.GetAIAgentAsync(string.Empty, options)); - - Assert.Equal("agentId", exception.ParamName); - } - - /// - /// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithServices_PassesServicesToAgentAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var serviceProvider = new TestServiceProvider(); - const string ModelId = "test-model"; - - // Act - var agent = await assistantClient.CreateAIAgentAsync( - ModelId, - instructions: "Test instructions", - name: "Test Agent", - services: serviceProvider); - - // Assert - Assert.NotNull(agent); - - // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var functionInvokingClient = chatClient.GetService(); - Assert.NotNull(functionInvokingClient); - Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); - } - - /// - /// Verify that CreateAIAgent with options and services parameter correctly passes it through to the ChatClientAgent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithOptionsAndServices_PassesServicesToAgentAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var serviceProvider = new TestServiceProvider(); - const string ModelId = "test-model"; - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - ChatOptions = new() { Instructions = "Test instructions" } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync(ModelId, options, services: serviceProvider); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - - // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var functionInvokingClient = chatClient.GetService(); - Assert.NotNull(functionInvokingClient); - Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); - } - - /// - /// Verify that AsAIAgent with services parameter correctly passes it through to the ChatClientAgent. - /// - [Fact] - public void AsAIAgent_WithServices_PassesServicesToAgent() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var serviceProvider = new TestServiceProvider(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent"}"""))!; - - // Act - var agent = assistantClient.AsAIAgent(assistant, services: serviceProvider); - - // Assert - Assert.NotNull(agent); - - // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var functionInvokingClient = chatClient.GetService(); - Assert.NotNull(functionInvokingClient); - Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); - } - - /// - /// Verify that GetAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent. - /// - [Fact] - public async Task GetAIAgentAsync_WithServices_PassesServicesToAgentAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var serviceProvider = new TestServiceProvider(); - - // Act - var agent = await assistantClient.GetAIAgentAsync("asst_abc123", services: serviceProvider); - - // Assert - Assert.NotNull(agent); - - // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var functionInvokingClient = chatClient.GetService(); - Assert.NotNull(functionInvokingClient); - Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); - } - - /// - /// Verify that CreateAIAgent with both clientFactory and services works correctly. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactoryAndServices_AppliesBothCorrectlyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var serviceProvider = new TestServiceProvider(); - var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model")); - const string ModelId = "test-model"; - - // Act - var agent = await assistantClient.CreateAIAgentAsync( - ModelId, - instructions: "Test instructions", - name: "Test Agent", - clientFactory: (innerClient) => testChatClient, - services: serviceProvider); - - // Assert - Assert.NotNull(agent); - - // Verify the custom chat client was applied - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - - // Verify the IServiceProvider was passed through - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var functionInvokingClient = chatClient.GetService(); - Assert.NotNull(functionInvokingClient); - Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); - } - - /// - /// Uses reflection to access the FunctionInvocationServices property which is not public. - /// - private static IServiceProvider? GetFunctionInvocationServices(FunctionInvokingChatClient client) - { - var property = typeof(FunctionInvokingChatClient).GetProperty( - "FunctionInvocationServices", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - return property?.GetValue(client) as IServiceProvider; - } - - /// - /// Verify that CreateAIAgentAsync with HostedCodeInterpreterTool properly adds CodeInterpreter tool definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithHostedCodeInterpreterTool_CreatesAgentWithToolAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - ChatOptions = new ChatOptions - { - Instructions = "Test instructions", - Tools = [new HostedCodeInterpreterTool()] - } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync(ModelId, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - } - - /// - /// Verify that CreateAIAgentAsync with HostedCodeInterpreterTool with HostedFileContent input properly creates agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithHostedCodeInterpreterToolAndHostedFileContent_CreatesAgentWithToolResourcesAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - var codeInterpreterTool = new HostedCodeInterpreterTool - { - Inputs = [new HostedFileContent("test-file-id")] - }; - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - ChatOptions = new ChatOptions - { - Instructions = "Test instructions", - Tools = [codeInterpreterTool] - } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync(ModelId, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - } - - /// - /// Verify that CreateAIAgentAsync with HostedFileSearchTool properly adds FileSearch tool definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithHostedFileSearchTool_CreatesAgentWithToolAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - ChatOptions = new ChatOptions - { - Instructions = "Test instructions", - Tools = [new HostedFileSearchTool()] - } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync(ModelId, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - } - - /// - /// Verify that CreateAIAgentAsync with HostedFileSearchTool with HostedVectorStoreContent input properly creates agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithHostedFileSearchToolAndHostedVectorStoreContent_CreatesAgentWithToolResourcesAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - var fileSearchTool = new HostedFileSearchTool - { - MaximumResultCount = 10, - Inputs = [new HostedVectorStoreContent("test-vector-store-id")] - }; - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - ChatOptions = new ChatOptions - { - Instructions = "Test instructions", - Tools = [fileSearchTool] - } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync(ModelId, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - } - - /// - /// Verify that CreateAIAgentAsync with multiple tools including functions properly creates agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithMixedTools_CreatesAgentWithAllToolsAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - var testFunction = AIFunctionFactory.Create(() => "test", "TestFunction", "A test function"); - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - ChatOptions = new ChatOptions - { - Instructions = "Test instructions", - Tools = [new HostedCodeInterpreterTool(), new HostedFileSearchTool(), testFunction] - } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync(ModelId, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - } - - /// - /// Verify that CreateAIAgentAsync with function tools properly categorizes them as other tools. - /// - [Fact] - public async Task CreateAIAgentAsync_WithFunctionTools_CategorizesAsOtherToolsAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string ModelId = "test-model"; - var testFunction = AIFunctionFactory.Create(() => "test", "TestFunction", "A test function"); - var options = new ChatClientAgentOptions - { - Name = "Test Agent", - ChatOptions = new ChatOptions - { - Instructions = "Test instructions", - Tools = [testFunction] - } - }; - - // Act - var agent = await assistantClient.CreateAIAgentAsync(ModelId, options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - } - - /// - /// Verify that AsAIAgent with legacy overload works correctly when assistant instructions are set. - /// - [Fact] - public void AsAIAgent_LegacyOverload_WithAssistantInstructions_SetsInstructions() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent", "instructions": "Original Instructions"}"""))!; - - // Act - var agent = assistantClient.AsAIAgent(assistant); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - Assert.Equal("Original Instructions", agent.Instructions); - } - - /// - /// Verify that AsAIAgent with legacy overload works correctly when chatOptions with instructions is provided. - /// - [Fact] - public void AsAIAgent_LegacyOverload_WithChatOptionsInstructions_UsesChatOptionsInstructions() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent", "instructions": "Original Instructions"}"""))!; - var chatOptions = new ChatOptions { Instructions = "Override Instructions" }; - - // Act - var agent = assistantClient.AsAIAgent(assistant, chatOptions); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - Assert.Equal("Override Instructions", agent.Instructions); - } - - /// - /// Verify that AsAIAgent with legacy overload and ClientResult works correctly. - /// - [Fact] - public void AsAIAgent_LegacyOverload_WithClientResult_WorksCorrectly() - { - // Arrange - var assistantClient = new TestAssistantClient(); - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent", "instructions": "Original Instructions"}"""))!; - var clientResult = ClientResult.FromValue(assistant, new FakePipelineResponse()); - - // Act - var agent = assistantClient.AsAIAgent(clientResult); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test Agent", agent.Name); - } - - /// - /// Verify that AsAIAgent with legacy overload throws ArgumentNullException when assistant client is null. - /// - [Fact] - public void AsAIAgent_LegacyOverload_WithNullAssistantClient_ThrowsArgumentNullException() - { - // Arrange - AssistantClient? assistantClient = null; - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}"""))!; - - // Act & Assert - var exception = Assert.Throws(() => - assistantClient!.AsAIAgent(assistant)); - - Assert.Equal("assistantClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with legacy overload throws ArgumentNullException when assistantMetadata is null. - /// - [Fact] - public void AsAIAgent_LegacyOverload_WithNullAssistantMetadata_ThrowsArgumentNullException() - { - // Arrange - var assistantClient = new TestAssistantClient(); - - // Act & Assert - var exception = Assert.Throws(() => - assistantClient.AsAIAgent((Assistant)null!)); - - Assert.Equal("assistantMetadata", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with legacy overload throws ArgumentNullException when clientResult is null. - /// - [Fact] - public void AsAIAgent_LegacyOverload_WithNullClientResult_ThrowsArgumentNullException() - { - // Arrange - var assistantClient = new TestAssistantClient(); - - // Act & Assert - var exception = Assert.Throws(() => - assistantClient.AsAIAgent(null!, chatOptions: null)); - - Assert.Equal("assistantClientResult", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with legacy overload works correctly. - /// - [Fact] - public async Task GetAIAgentAsync_LegacyOverload_WorksCorrectlyAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - const string AgentId = "asst_abc123"; - - // Act - var agent = await assistantClient.GetAIAgentAsync(AgentId); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Original Name", agent.Name); - } - - /// - /// Verify that GetAIAgentAsync with legacy overload throws ArgumentNullException when assistantClient is null. - /// - [Fact] - public async Task GetAIAgentAsync_LegacyOverload_WithNullAssistantClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AssistantClient? assistantClient = null; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - assistantClient!.GetAIAgentAsync("asst_abc123")); - - Assert.Equal("assistantClient", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with legacy overload throws ArgumentException when agentId is empty. - /// - [Fact] - public async Task GetAIAgentAsync_LegacyOverload_WithEmptyAgentId_ThrowsArgumentExceptionAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - assistantClient.GetAIAgentAsync(string.Empty)); - - Assert.Equal("agentId", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with options throws ArgumentNullException when assistantClient is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullAssistantClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AssistantClient? assistantClient = null; - var options = new ChatClientAgentOptions(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - assistantClient!.GetAIAgentAsync("asst_abc123", options)); - - Assert.Equal("assistantClient", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with options throws ArgumentNullException when options is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullOptions_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var assistantClient = new TestAssistantClient(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - assistantClient.GetAIAgentAsync("asst_abc123", (ChatClientAgentOptions)null!)); - - Assert.Equal("options", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with options throws ArgumentNullException when assistantClient is null. - /// - [Fact] - public void AsAIAgent_WithOptions_WithNullAssistantClient_ThrowsArgumentNullException() - { - // Arrange - AssistantClient? assistantClient = null; - var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}"""))!; - var options = new ChatClientAgentOptions(); - - // Act & Assert - var exception = Assert.Throws(() => - assistantClient!.AsAIAgent(assistant, options)); - - Assert.Equal("assistantClient", exception.ParamName); - } - - /// - /// Creates a test AssistantClient implementation for testing. - /// - private sealed class TestAssistantClient : AssistantClient - { - public TestAssistantClient() - { - } - - public override Task> CreateAssistantAsync(string model, AssistantCreationOptions? options = null, CancellationToken cancellationToken = default) - { - return Task.FromResult>(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!); - } - - public override async Task> GetAssistantAsync(string assistantId, CancellationToken cancellationToken = default) - { - await Task.Delay(1, cancellationToken); // Simulate async operation - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}""")), new FakePipelineResponse())!; - } - } - - private sealed class TestChatClient : DelegatingChatClient - { - public TestChatClient(IChatClient innerClient) : base(innerClient) - { - } - } - - private sealed class TestServiceProvider : IServiceProvider - { - public object? GetService(Type serviceType) => null; - } - - private sealed class FakePipelineResponse : PipelineResponse - { - public override int Status => throw new NotImplementedException(); - - public override string ReasonPhrase => throw new NotImplementedException(); - - public override Stream? ContentStream { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - public override BinaryData Content => throw new NotImplementedException(); - - protected override PipelineResponseHeaders HeadersCore => throw new NotImplementedException(); - - public override BinaryData BufferContent(CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public override void Dispose() - { - throw new NotImplementedException(); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs new file mode 100644 index 0000000000..2c453f21b6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for and . +/// +public sealed class AgentClassSkillTests +{ + [Fact] + public void Resources_DefaultsToNull_WhenNotOverridden() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Null(skill.Resources); + } + + [Fact] + public void Scripts_DefaultsToNull_WhenNotOverridden() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Null(skill.Scripts); + } + + [Fact] + public void Resources_ReturnsOverriddenList_WhenOverridden() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.Single(resources!); + Assert.Equal("test-resource", resources![0].Name); + } + + [Fact] + public void Scripts_ReturnsOverriddenList_WhenOverridden() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var scripts = skill.Scripts; + + // Assert + Assert.Single(scripts!); + Assert.Equal("TestScript", scripts![0].Name); + } + + [Fact] + public void ResourcesAndScripts_CanBeLazyLoaded_AndCached() + { + // Arrange + var skill = new LazyLoadedSkill(); + + // Act & Assert + Assert.Equal(0, skill.ResourceCreationCount); + Assert.Equal(0, skill.ScriptCreationCount); + + var firstResources = skill.Resources; + var firstScripts = skill.Scripts; + var secondResources = skill.Resources; + var secondScripts = skill.Scripts; + + Assert.Single(firstResources!); + Assert.Single(firstScripts!); + Assert.Same(firstResources, secondResources); + Assert.Same(firstScripts, secondScripts); + Assert.Equal(1, skill.ResourceCreationCount); + Assert.Equal(1, skill.ScriptCreationCount); + } + + [Fact] + public void Name_Content_ReturnClassDefinedValues() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Equal("minimal", skill.Frontmatter.Name); + Assert.Contains("", skill.Content); + Assert.Contains("Minimal skill body.", skill.Content); + Assert.Contains("", skill.Content); + } + + [Fact] + public void Content_ReturnsSynthesizedXmlDocument() + { + // Arrange + var skill = new MinimalClassSkill(); + + // Act & Assert + Assert.Contains("minimal", skill.Content); + Assert.Contains("A minimal skill.", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("Minimal skill body.", skill.Content); + } + + [Fact] + public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync() + { + // Arrange + var skills = new AgentClassSkill[] { new MinimalClassSkill(), new FullClassSkill() }; + var source = new AgentInMemorySkillsSource(skills); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("minimal", result[0].Frontmatter.Name); + Assert.Equal("full", result[1].Frontmatter.Name); + } + + [Fact] + public void AgentClassSkill_InvalidFrontmatter_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("INVALID-NAME", "An invalid skill.")); + } + + [Fact] + public void SkillWithOnlyResources_HasNullScripts() + { + // Arrange + var skill = new ResourceOnlySkill(); + + // Act & Assert + Assert.Single(skill.Resources!); + Assert.Null(skill.Scripts); + } + + [Fact] + public void SkillWithOnlyScripts_HasNullResources() + { + // Arrange + var skill = new ScriptOnlySkill(); + + // Act & Assert + Assert.Null(skill.Resources); + Assert.Single(skill.Scripts!); + } + + [Fact] + public void Content_ReturnsCachedInstance_OnRepeatedAccess() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var first = skill.Content; + var second = skill.Content; + + // Assert + Assert.Same(first, second); + } + + [Fact] + public void Content_IncludesParametersSchema_WhenScriptsHaveParameters() + { + // Arrange + var skill = new FullClassSkill(); + + // Act + var content = skill.Content; + + // Assert — scripts with typed parameters should have their schema included + Assert.Contains("parameters_schema", content); + Assert.Contains("value", content); + } + + [Fact] + public void Content_IncludesDerivedResources_WhenResourcesUseBaseTypeOverrides() + { + // Arrange + var skill = new DerivedResourceSkill(); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("custom-resource", content); + Assert.Contains("Custom resource description.", content); + } + + [Fact] + public void Content_IncludesDerivedScripts_WhenScriptsUseBaseTypeOverrides() + { + // Arrange + var skill = new DerivedScriptSkill(); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("custom-script", content); + Assert.Contains("Custom script description.", content); + } + + [Fact] + public void Content_OmitsParametersSchema_WhenDerivedScriptDoesNotProvideOne() + { + // Arrange + var skill = new DerivedScriptSkill(); + + // Act + var content = skill.Content; + + // Assert + Assert.DoesNotContain("parameters_schema", content); + } + + #region Test skill classes + + private sealed class MinimalClassSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill."); + + protected override string Instructions => "Minimal skill body."; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } + + private sealed class FullClassSkill : AgentClassSkill + { + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts."); + + protected override string Instructions => "Full skill body."; + + public override IReadOnlyList? Resources => this._resources ??= + [ + CreateResource("test-resource", "resource content"), + ]; + + public override IReadOnlyList? Scripts => this._scripts ??= + [ + CreateScript("TestScript", TestScript), + ]; + + private static string TestScript(double value) => + JsonSerializer.Serialize(new { result = value * 2 }); + } + + private sealed class ResourceOnlySkill : AgentClassSkill + { + private IReadOnlyList? _resources; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => this._resources ??= + [ + CreateResource("data", "some data"), + ]; + + public override IReadOnlyList? Scripts => null; + } + + private sealed class ScriptOnlySkill : AgentClassSkill + { + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => this._scripts ??= + [ + CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), + ]; + } + + private sealed class DerivedResourceSkill : AgentClassSkill + { + private IReadOnlyList? _resources; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-resource", "Skill with a derived resource type."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => this._resources ??= + [ + new CustomResource("custom-resource", "Custom resource description."), + ]; + + public override IReadOnlyList? Scripts => null; + } + + private sealed class DerivedScriptSkill : AgentClassSkill + { + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-script", "Skill with a derived script type."); + + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => this._scripts ??= + [ + new CustomScript("custom-script", "Custom script description."), + ]; + } + + private sealed class LazyLoadedSkill : AgentClassSkill + { + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + + public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts."); + + protected override string Instructions => "Body."; + + public int ResourceCreationCount { get; private set; } + + public int ScriptCreationCount { get; private set; } + + public override IReadOnlyList? Resources => this._resources ??= this.CreateResources(); + + public override IReadOnlyList? Scripts => this._scripts ??= this.CreateScripts(); + + private IReadOnlyList CreateResources() + { + this.ResourceCreationCount++; + return [CreateResource("lazy-resource", "resource content")]; + } + + private IReadOnlyList CreateScripts() + { + this.ScriptCreationCount++; + return [CreateScript("LazyScript", () => "done")]; + } + } + + private sealed class CustomResource : AgentSkillResource + { + public CustomResource(string name, string? description = null) + : base(name, description) + { + } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + => Task.FromResult("resource-value"); + } + + private sealed class CustomScript : AgentSkillScript + { + public CustomScript(string name, string? description = null) + : base(name, description) + { + } + + public override Task RunAsync(AgentSkill skill, Extensions.AI.AIFunctionArguments arguments, CancellationToken cancellationToken = default) + => Task.FromResult("script-result"); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index 87e98b3da3..e86eb0894a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -851,6 +851,61 @@ public sealed class AgentSkillsProviderTests : IDisposable Assert.Contains("First instructions.", content!.ToString()!); } + [Fact] + public async Task Constructor_ClassSkillsParams_ProvidesSkillsAsync() + { + // Arrange + var skill = new TestClassSkill("class-a", "Class A", "Class instructions."); + var provider = new AgentSkillsProvider(skill); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("class-a", result.Instructions); + } + + [Fact] + public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync() + { + // Arrange + var skills = new List + { + new TestClassSkill("enum-class-a", "Class A", "Instructions A."), + new TestClassSkill("enum-class-b", "Class B", "Instructions B."), + }; + var provider = new AgentSkillsProvider(skills); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("enum-class-a", result.Instructions); + Assert.Contains("enum-class-b", result.Instructions); + } + + [Fact] + public async Task Constructor_ClassSkills_DeduplicatesAsync() + { + // Arrange — two class skills with the same name + var skill1 = new TestClassSkill("dup-class", "First", "First instructions."); + var skill2 = new TestClassSkill("dup-class", "Second", "Second instructions."); + var provider = new AgentSkillsProvider(skill1, skill2); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-class" })); + + // Assert — only first occurrence survives + Assert.Contains("First instructions.", content!.ToString()!); + } + /// /// A test skill source that counts how many times is called. /// @@ -872,4 +927,23 @@ public sealed class AgentSkillsProviderTests : IDisposable return Task.FromResult(this._skills); } } + + private sealed class TestClassSkill : AgentClassSkill + { + private readonly string _instructions; + + public TestClassSkill(string name, string description, string instructions) + { + this.Frontmatter = new AgentSkillFrontmatter(name, description); + this._instructions = instructions; + } + + public override AgentSkillFrontmatter Frontmatter { get; } + + protected override string Instructions => this._instructions; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs index b3296a1306..d7ab9b2808 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.UnitTests; /// /// Shared test helper for integration tests that verify -/// end-to-end behavior with and +/// end-to-end behavior with and /// . /// internal static class ChatClientAgentTestHelper diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs index f3550359dc..36bd2c70dd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.UnitTests; /// /// Contains unit tests that verify the end-to-end approval flow behavior of the -/// class with , +/// class with , /// ensuring that chat history is correctly persisted across multi-turn approval interactions. /// public class ChatClientAgent_ApprovalsTests @@ -48,7 +48,7 @@ public class ChatClientAgent_ApprovalsTests agentOptions: new() { ChatOptions = new() { Tools = [approvalTool] }, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }, callIndex: callIndex, capturedInputs: capturedInputs); @@ -260,7 +260,7 @@ public class ChatClientAgent_ApprovalsTests agentOptions: new() { ChatOptions = new() { Tools = [approvalTool] }, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }, callIndex: callIndex, capturedInputs: capturedInputs); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs index 83bdc2b2a2..410ee4edda 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -520,7 +520,7 @@ public class ChatClientAgent_ChatHistoryManagementTests agentOptions: new() { ChatOptions = new() { Instructions = "Be helpful" }, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }, expectedServiceCallCount: 1, expectedHistory: @@ -554,7 +554,7 @@ public class ChatClientAgent_ChatHistoryManagementTests agentOptions: new() { ChatOptions = new() { Tools = [tool] }, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }, expectedServiceCallCount: 2, expectedHistory: diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ServiceStoredSimulatingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs similarity index 94% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ServiceStoredSimulatingChatClientTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs index ea346dade9..8613d37747 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ServiceStoredSimulatingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/PerServiceCallChatHistoryPersistingChatClientTests.cs @@ -13,15 +13,15 @@ using Moq.Protected; namespace Microsoft.Agents.AI.UnitTests; /// -/// Contains unit tests for the decorator, +/// Contains unit tests for the decorator, /// verifying that it persists messages via the after each /// individual service call by default, or marks messages for end-of-run persistence when the -/// option is enabled. +/// option is enabled. /// -public class ServiceStoredSimulatingChatClientTests +public class PerServiceCallChatHistoryPersistingChatClientTests { /// - /// Verifies that by default (SimulateServiceStoredChatHistory is false), + /// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false), /// the ChatHistoryProvider receives messages after a successful non-streaming call. /// [Fact] @@ -50,7 +50,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -97,7 +97,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -145,7 +145,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -163,7 +163,7 @@ public class ServiceStoredSimulatingChatClientTests } /// - /// Verifies that the decorator is NOT injected by default (SimulateServiceStoredChatHistory is false). + /// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false). /// [Fact] public void ChatClient_DoesNotContainDecorator_ByDefault() @@ -175,15 +175,15 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new()); // Assert - var decorator = agent.ChatClient.GetService(); + var decorator = agent.ChatClient.GetService(); Assert.Null(decorator); } /// - /// Verifies that the decorator is injected when SimulateServiceStoredChatHistory is true. + /// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true. /// [Fact] - public void ChatClient_ContainsDecorator_WhenSimulateServiceStoredChatHistory() + public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence() { // Arrange Mock mockService = new(); @@ -191,11 +191,11 @@ public class ServiceStoredSimulatingChatClientTests // Act ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Assert - var decorator = agent.ChatClient.GetService(); + var decorator = agent.ChatClient.GetService(); Assert.NotNull(decorator); } @@ -215,27 +215,27 @@ public class ServiceStoredSimulatingChatClientTests }); // Assert - var decorator = agent.ChatClient.GetService(); + var decorator = agent.ChatClient.GetService(); Assert.Null(decorator); } /// - /// Verifies that the SimulateServiceStoredChatHistory option is included in Clone(). + /// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone(). /// [Fact] - public void ChatClientAgentOptions_Clone_IncludesSimulateServiceStoredChatHistory() + public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence() { // Arrange var options = new ChatClientAgentOptions { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }; // Act var cloned = options.Clone(); // Assert - Assert.True(cloned.SimulateServiceStoredChatHistory); + Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence); } /// @@ -289,7 +289,7 @@ public class ServiceStoredSimulatingChatClientTests { ChatOptions = new() { Tools = [tool] }, ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }, services: new ServiceCollection().BuildServiceProvider()); // Act @@ -358,7 +358,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -407,7 +407,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviders = [mockContextProvider.Object], - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -454,7 +454,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviders = [mockContextProvider.Object], - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -513,7 +513,7 @@ public class ServiceStoredSimulatingChatClientTests { ChatHistoryProvider = mockChatHistoryProvider.Object, AIContextProviders = [mockContextProvider.Object], - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -587,7 +587,7 @@ public class ServiceStoredSimulatingChatClientTests { ChatOptions = new() { Tools = [tool] }, ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }, services: new ServiceCollection().BuildServiceProvider()); // Act @@ -652,7 +652,7 @@ public class ServiceStoredSimulatingChatClientTests { ChatOptions = new() { Tools = [tool] }, ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }, services: new ServiceCollection().BuildServiceProvider()); // Act @@ -720,8 +720,8 @@ public class ServiceStoredSimulatingChatClientTests /// /// Verifies that when per-service-call persistence is active and no real conversation ID exists, - /// sets the - /// sentinel on the chat options and strips it before + /// sets the + /// sentinel on the chat options and strips it before /// forwarding to the inner client. /// [Fact] @@ -741,7 +741,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test" }, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -773,7 +773,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test" }, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -808,7 +808,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Create a session with a real conversation ID. @@ -842,7 +842,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test" }, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -862,7 +862,7 @@ public class ServiceStoredSimulatingChatClientTests /// skip provider resolution in the agent (the decorator handles it). /// [Fact] - public async Task RunAsync_SetsSentinelOnSession_WhenSimulateServiceStoredChatHistoryActiveAsync() + public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync() { // Arrange Mock mockService = new(); @@ -875,7 +875,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -883,7 +883,7 @@ public class ServiceStoredSimulatingChatClientTests await agent.RunAsync([new(ChatRole.User, "test")], session); // Assert — session should have the sentinel conversation ID - Assert.Equal(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId); + Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId); } /// @@ -924,7 +924,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act & Assert — conflict detection should throw @@ -969,7 +969,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, AIContextProviders = [mockContextProvider.Object], }); @@ -1025,7 +1025,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, AIContextProviders = [mockContextProvider.Object], }); @@ -1077,7 +1077,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, AIContextProviders = [mockContextProvider.Object], }); @@ -1137,7 +1137,7 @@ public class ServiceStoredSimulatingChatClientTests // No ChatHistoryProvider — so conflict detection won't throw. ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, AIContextProviders = [mockContextProvider.Object], }); @@ -1192,7 +1192,7 @@ public class ServiceStoredSimulatingChatClientTests // No ChatHistoryProvider — so conflict detection won't throw. ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, AIContextProviders = [mockContextProvider.Object], }); @@ -1253,7 +1253,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { ChatHistoryProvider = mockChatHistoryProvider.Object, - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -1270,7 +1270,7 @@ public class ServiceStoredSimulatingChatClientTests Assert.Equal("test", messageList[0].Text); // Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run) - Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId); + Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId); } /// @@ -1291,7 +1291,7 @@ public class ServiceStoredSimulatingChatClientTests ChatClientAgent agent = new(mockService.Object, options: new() { - SimulateServiceStoredChatHistory = true, + RequirePerServiceCallChatHistoryPersistence = true, }); // Act @@ -1309,6 +1309,6 @@ public class ServiceStoredSimulatingChatClientTests Assert.NotEmpty(updates); // Assert — session should NOT have the sentinel - Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId); + Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs index 58e6b96d10..6fa684891f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs @@ -35,13 +35,13 @@ internal abstract class AgentProvider(IConfiguration configuration) { Uri foundryEndpoint = new(this.GetSetting(TestSettings.AzureAIProjectEndpoint)); - await foreach (AgentVersion agent in this.CreateAgentsAsync(foundryEndpoint)) + await foreach (ProjectsAgentVersion agent in this.CreateAgentsAsync(foundryEndpoint)) { Console.WriteLine($"Created agent: {agent.Name}:{agent.Version}"); } } - protected abstract IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint); + protected abstract IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint); protected string GetSetting(string settingName) => configuration[settingName] ?? diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs index 05ad68fa3d..b6f4d7146a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; internal sealed class FunctionToolAgentProvider(IConfiguration configuration) : AgentProvider(configuration) { - protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) { MenuPlugin menuPlugin = new(); AIFunction[] functions = @@ -33,9 +33,9 @@ internal sealed class FunctionToolAgentProvider(IConfiguration configuration) : agentDescription: "Provides information about the restaurant menu"); } - private PromptAgentDefinition DefineMenuAgent(AIFunction[] functions) + private DeclarativeAgentDefinition DefineMenuAgent(AIFunction[] functions) { - PromptAgentDefinition agentDefinition = + DeclarativeAgentDefinition agentDefinition = new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs index b25e921abe..8be61587e9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; internal sealed class MarketingAgentProvider(IConfiguration configuration) : AgentProvider(configuration) { - protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) { AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential()); @@ -35,7 +35,7 @@ internal sealed class MarketingAgentProvider(IConfiguration configuration) : Age agentDescription: "Editor agent for Marketing workflow"); } - private PromptAgentDefinition DefineAnalystAgent() => + private DeclarativeAgentDefinition DefineAnalystAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = @@ -47,13 +47,13 @@ internal sealed class MarketingAgentProvider(IConfiguration configuration) : Age """, Tools = { - //AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available + //ProjectsAgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available // new BingGroundingSearchToolParameters( // [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))])) } }; - private PromptAgentDefinition DefineWriterAgent() => + private DeclarativeAgentDefinition DefineWriterAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = @@ -64,7 +64,7 @@ internal sealed class MarketingAgentProvider(IConfiguration configuration) : Age """ }; - private PromptAgentDefinition DefineEditorAgent() => + private DeclarativeAgentDefinition DefineEditorAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs index c65cc3ce57..8444734793 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; internal sealed class MathChatAgentProvider(IConfiguration configuration) : AgentProvider(configuration) { - protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) { AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential()); @@ -29,7 +29,7 @@ internal sealed class MathChatAgentProvider(IConfiguration configuration) : Agen agentDescription: "Teacher agent for MathChat workflow"); } - private PromptAgentDefinition DefineStudentAgent() => + private DeclarativeAgentDefinition DefineStudentAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = @@ -41,7 +41,7 @@ internal sealed class MathChatAgentProvider(IConfiguration configuration) : Agen """ }; - private PromptAgentDefinition DefineTeacherAgent() => + private DeclarativeAgentDefinition DefineTeacherAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs index 6e3912a276..3c537f7c91 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentProvider(configuration) { - protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) { AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential()); @@ -23,7 +23,7 @@ internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentPro agentDescription: "Authors original poems"); } - private PromptAgentDefinition DefinePoemAgent() => + private DeclarativeAgentDefinition DefinePoemAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs index e1278e6fb7..2fc3ad5665 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; internal sealed class TestAgentProvider(IConfiguration configuration) : AgentProvider(configuration) { - protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) { AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential()); @@ -23,6 +23,6 @@ internal sealed class TestAgentProvider(IConfiguration configuration) : AgentPro agentDescription: "Basic agent"); } - private PromptAgentDefinition DefineMenuAgent() => + private DeclarativeAgentDefinition DefineMenuAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs index 027a67e254..8c3b35757e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; internal sealed class VisionAgentProvider(IConfiguration configuration) : AgentProvider(configuration) { - protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) { AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential()); @@ -23,7 +23,7 @@ internal sealed class VisionAgentProvider(IConfiguration configuration) : AgentP agentDescription: "Use computer vision to describe an image or document."); } - private PromptAgentDefinition DefineVisionAgent() => + private DeclarativeAgentDefinition DefineVisionAgent() => new(this.GetSetting(TestSettings.AzureAIModelDeploymentName)) { Instructions = diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs index 0efb0c19c4..921fafb61d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs @@ -29,11 +29,11 @@ public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowT [InlineData("MathChat.yaml", "MathChat.json", true)] [InlineData("DeepResearch.yaml", "DeepResearch.json", Skip = "Long running")] public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) => - this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration); + this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "declarative-agents", "workflow-samples", workflowFileName), testcaseFileName, externalConveration); [Fact(Skip = "Needs template support")] public Task ValidateMultiTurnAsync() => - this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true); + this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "declarative-agents", "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true); protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs index eb1d0f55a2..ea260949fc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -41,7 +41,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow private static string GetWorkflowPath(string workflowFileName, bool isSample) => isSample - ? Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName) + ? Path.Combine(GetRepoFolder(), "declarative-agents", "workflow-samples", workflowFileName) : Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index ee2a3b4815..8f46d91bbc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -126,6 +126,13 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId) { hasRequest = true; } + else + { + // This is a republished event for the request we're already responding to + // (emitted by RepublishUnservicedRequestsAsync during checkpoint resume). + // Skip yielding it so downstream code doesn't treat it as a new pending request. + continue; + } break; case ConversationUpdateEvent conversationEvent: diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs index 43e64bbb35..85e133a023 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs @@ -94,7 +94,7 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o while (current is not null) { - if (Directory.Exists(Path.Combine(current.FullName, "workflow-samples"))) + if (Directory.Exists(Path.Combine(current.FullName, "declarative-agents", "workflow-samples"))) { return current.FullName; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 37c0fa98cf..01caa7c1e9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ Always - + Never diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AggregatingExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AggregatingExecutorTests.cs new file mode 100644 index 0000000000..fac76744e9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AggregatingExecutorTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class AggregatingExecutorTests +{ + [Fact] + public async Task AggregatingExecutor_HandleAsync_AggregatesIncrementallyAsync() + { + AggregatingExecutor executor = new("sum", (aggregate, input) => + aggregate == null ? input : $"{aggregate}+{input}"); + + TestWorkflowContext context = new(executor.Id); + + string? result1 = await executor.HandleAsync("a", context, default); + string? result2 = await executor.HandleAsync("b", context, default); + string? result3 = await executor.HandleAsync("c", context, default); + + result1.Should().Be("a"); + result2.Should().Be("a+b"); + result3.Should().Be("a+b+c"); + } + + [Fact] + public async Task AggregatingExecutor_HandleAsync_FirstCallReceivesNullAggregateAsync() + { + string? receivedAggregate = "sentinel"; + + AggregatingExecutor executor = new("first-call", (aggregate, input) => + { + receivedAggregate = aggregate; + return input; + }); + + TestWorkflowContext context = new(executor.Id); + await executor.HandleAsync("hello", context, default); + + receivedAggregate.Should().BeNull("the first invocation should receive a null aggregate for reference types"); + } + + [Fact] + public async Task AggregatingExecutor_HandleAsync_AggregatorReturningNullClearsStateAsync() + { + AggregatingExecutor executor = new("nullable", (aggregate, input) => + input == "clear" ? null : (aggregate ?? "") + input); + + TestWorkflowContext context = new(executor.Id); + + string? result1 = await executor.HandleAsync("a", context, default); + result1.Should().Be("a"); + + string? result2 = await executor.HandleAsync("clear", context, default); + result2.Should().BeNull("the aggregator returned null to clear the state"); + + // After clearing, the next call should receive null aggregate again + string? result3 = await executor.HandleAsync("b", context, default); + result3.Should().Be("b", "the aggregate should restart from null after being cleared"); + } + + [Fact] + public async Task AggregatingExecutor_HandleAsync_PersistsStateBetweenCallsAsync() + { + AggregatingExecutor executor = new("counter", (aggregate, _) => + aggregate == null ? "1" : $"{int.Parse(aggregate) + 1}"); + + TestWorkflowContext context = new(executor.Id); + + for (int i = 1; i <= 5; i++) + { + string? result = await executor.HandleAsync("tick", context, default); + result.Should().Be($"{i}", "the aggregate should increment with each call"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs new file mode 100644 index 0000000000..9d4b514af7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Agents.AI.Workflows.Sample; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Regression tests for GH-2485: pending objects must be +/// re-emitted after resuming a workflow from a checkpoint. +/// +public class CheckpointResumeTests +{ + /// + /// Verifies that a resumed workflow re-emits s for + /// pending external requests that existed at the time of the checkpoint. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // Act 1: Run workflow, collect pending requests and a checkpoint. + List originalRequests = []; + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is RequestInfoEvent requestInfo) + { + originalRequests.Add(requestInfo.Request); + } + + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + originalRequests.Should().NotBeEmpty("the workflow should have created at least one external request"); + checkpoint.Should().NotBeNull("a checkpoint should have been created"); + } + + // Act 2: Resume from the checkpoint. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint!); + + // Assert: The pending requests should be re-emitted. + List reEmittedRequests = []; + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + if (evt is RequestInfoEvent requestInfo) + { + reEmittedRequests.Add(requestInfo.Request); + } + } + + reEmittedRequests.Should().HaveCount(originalRequests.Count, + "all pending requests from the checkpoint should be re-emitted after resume"); + reEmittedRequests.Select(r => r.RequestId) + .Should().BeEquivalentTo(originalRequests.Select(r => r.RequestId), + "the re-emitted request IDs should match the original pending request IDs"); + } + + /// + /// Verifies that transitions to + /// after resuming from a checkpoint with pending external requests (not stuck at NotStarted). + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_WithPendingRequests_RunStatusIsPendingRequestsAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // First run: collect a checkpoint with pending requests. + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + checkpoint.Should().NotBeNull(); + } + + // Act: Resume from the checkpoint and consume events so the run loop processes. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint!); + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + // Consume all events until the stream completes. + } + + // Assert + RunStatus status = await resumed.GetStatusAsync(); + status.Should().Be(RunStatus.PendingRequests, + "the resumed workflow should report PendingRequests after rehydration"); + } + + /// + /// Verifies the full roundtrip: resume from checkpoint, observe the re-emitted request, + /// send a response, and verify the workflow completes without duplicating the request. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_RespondToPendingRequest_CompletesWithoutDuplicateAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // First run: collect checkpoint + pending request. + ExternalRequest? pendingRequest = null; + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is RequestInfoEvent requestInfo) + { + pendingRequest = requestInfo.Request; + } + + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + pendingRequest.Should().NotBeNull(); + checkpoint.Should().NotBeNull(); + } + + // Act: Resume and respond to the restored request. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint!); + + int requestEventCount = 0; + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + + // Use blockOnPendingRequest: false for the first pass to see the re-emitted requests. + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + if (evt is RequestInfoEvent requestInfo) + { + requestEventCount++; + requestInfo.Request.RequestId.Should().Be(pendingRequest!.RequestId, + "the re-emitted request should match the original"); + } + } + + requestEventCount.Should().Be(1, + "the pending request should be emitted exactly once (no duplicates)"); + + // Assert intermediate state before responding: the run should be in PendingRequests + // and we should have observed the re-emitted request. If the first WatchStreamAsync + // didn't complete or yielded nothing, these assertions catch it with a clear message. + RunStatus statusBeforeResponse = await resumed.GetStatusAsync(); + statusBeforeResponse.Should().Be(RunStatus.PendingRequests, + "the run should be in PendingRequests state before we send a response"); + + // Now send the response and verify the workflow processes it. + ExternalResponse response = pendingRequest!.CreateResponse("World"); + await resumed.SendResponseAsync(response); + + // Consume the resulting events to verify the workflow progresses without errors. + List postResponseEvents = []; + + using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(10)); + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token)) + { + postResponseEvents.Add(evt); + } + + postResponseEvents.Should().NotBeEmpty( + "the workflow should process the response and produce events"); + postResponseEvents.OfType().Should().BeEmpty( + "no errors should occur when processing the restored request's response"); + } + + /// + /// Verifies that restoring a live run to a checkpoint re-emits pending requests and allows + /// the workflow to continue from that restored point. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Restore_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + Workflow workflow = CreateSimpleRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + await using StreamingRun run = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello"); + + (ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run); + + // Advance the run past the checkpoint so the restore has meaningful work to undo. + await run.SendResponseAsync(pendingRequest.CreateResponse("World")); + + List firstCompletionEvents = await ReadToHaltAsync(run); + firstCompletionEvents.OfType().Should().BeEmpty( + "the workflow should continue cleanly before we restore"); + RunStatus statusAfterFirstResponse = await run.GetStatusAsync(); + statusAfterFirstResponse.Should().Be(RunStatus.Idle, + "the workflow should finish processing the first response before we restore"); + + // Act + await run.RestoreCheckpointAsync(checkpoint); + + // Assert + List restoredEvents = await ReadToHaltAsync(run); + ExternalRequest[] replayedRequests = [.. restoredEvents.OfType().Select(evt => evt.Request)]; + + replayedRequests.Should().ContainSingle("runtime restore should re-emit the restored pending request"); + replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId, + "the replayed request should match the request captured at the checkpoint"); + + await run.SendResponseAsync(replayedRequests[0].CreateResponse("Again")); + + List secondCompletionEvents = await ReadToHaltAsync(run); + secondCompletionEvents.OfType().Should().BeEmpty( + "runtime restore replay should not introduce workflow errors"); + RunStatus statusAfterRestoreResponse = await run.GetStatusAsync(); + statusAfterRestoreResponse.Should().Be(RunStatus.Idle, + "the workflow should be able to continue after the runtime restore replay"); + } + + /// + /// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_SubworkflowWithPendingRequests_RepublishesQualifiedRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + Workflow workflow = CreateCheckpointedSubworkflowRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + ExternalRequest pendingRequest; + CheckpointInfo checkpoint; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + (pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun); + } + + // Act + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint); + + // Assert + List resumedEvents = await ReadToHaltAsync(resumed); + ExternalRequest[] replayedRequests = [.. resumedEvents.OfType().Select(evt => evt.Request)]; + + replayedRequests.Should().ContainSingle("the resumed parent workflow should surface the subworkflow request once"); + replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId, + "the replayed subworkflow request should match the checkpointed request"); + replayedRequests[0].PortInfo.PortId.Should().Be(pendingRequest.PortInfo.PortId, + "the replayed request should remain qualified through the subworkflow boundary"); + + await resumed.SendResponseAsync(replayedRequests[0].CreateResponse("World")); + + List completionEvents = await ReadToHaltAsync(resumed); + completionEvents.OfType().Should().BeEmpty( + "the resumed subworkflow request should not be replayed twice"); + completionEvents.OfType().Should().BeEmpty( + "subworkflow replay should not introduce workflow errors"); + RunStatus statusAfterSubworkflowResponse = await resumed.GetStatusAsync(); + statusAfterSubworkflowResponse.Should().Be(RunStatus.Idle, + "the resumed subworkflow should continue after responding to the replayed request"); + } + + /// + /// Verifies that when republishPendingEvents is , + /// no is re-emitted after resuming from a checkpoint. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_WithRepublishDisabled_DoesNotEmitRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // First run: collect a checkpoint with pending requests. + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + checkpoint.Should().NotBeNull(); + } + + // Act: Resume with republishPendingEvents: false via the internal API. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingInternalAsync(workflow, checkpoint!, republishPendingEvents: false); + + // Assert: No RequestInfoEvent should appear in the event stream. + int requestEventCount = 0; + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + if (evt is RequestInfoEvent) + { + requestEventCount++; + } + } + + requestEventCount.Should().Be(0, + "no RequestInfoEvent should be emitted when republishPendingEvents is false"); + } + + private static Workflow CreateSimpleRequestWorkflow( + string requestPortId = "TestPort", + string processorId = "Processor") + { + RequestPort requestPort = RequestPort.Create(requestPortId); + ForwardMessageExecutor processor = new(processorId); + + return new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + } + + private static Workflow CreateCheckpointedSubworkflowRequestWorkflow() + { + ExecutorBinding subworkflow = CreateSimpleRequestWorkflow( + requestPortId: "InnerTestPort", + processorId: "InnerProcessor") + .BindAsExecutor("Subworkflow"); + + return new WorkflowBuilder(subworkflow) + .AddExternalRequest(subworkflow, id: "ForwardedSubworkflowRequest") + .Build(); + } + + private static async ValueTask<(ExternalRequest PendingRequest, CheckpointInfo Checkpoint)> CapturePendingRequestAndCheckpointAsync(StreamingRun run) + { + ExternalRequest? pendingRequest = null; + CheckpointInfo? checkpoint = null; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is RequestInfoEvent requestInfo) + { + pendingRequest ??= requestInfo.Request; + } + + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + pendingRequest.Should().NotBeNull("the workflow should have emitted a pending request"); + checkpoint.Should().NotBeNull("the workflow should have produced a checkpoint"); + return (pendingRequest!, checkpoint!); + } + + private static async ValueTask> ReadToHaltAsync(StreamingRun run) + { + List events = []; + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + events.Add(evt); + } + + return events; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs index 76f37714fd..870d100d76 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs @@ -132,6 +132,53 @@ public class InProcessExecutionTests "both versions should produce the same number of agent events"); } + /// + /// This test checks that the logic around waiting for input and halting appropriately works right when the + /// workflow runs to halting before the EventStream is watched by the user. + /// + [Fact] + public async Task RunStreamingAsyncWaitToTakeStreamAsync() + { + // Arrange: Create a simple agent that responds to messages + var agent = new SimpleTestAgent("test-agent"); + var workflow = AgentWorkflowBuilder.BuildSequential(agent); + var inputMessage = new ChatMessage(ChatRole.User, "Hello"); + + // Act: Execute using streaming version with TurnToken + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, new List { inputMessage }); + + // Send TurnToken to actually trigger execution (this is the key step) + bool messageSent = await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + messageSent.Should().BeTrue("TurnToken should be accepted"); + + while (await run.GetStatusAsync() != RunStatus.Idle) + { + await Task.Delay(200); + } + + // Collect events + List events = []; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert: The workflow should have executed and produced events + RunStatus status = await run.GetStatusAsync(); + status.Should().Be(RunStatus.Idle, "workflow should complete execution"); + + events.Should().NotBeEmpty("workflow should produce events during execution"); + + // Check that we have agent execution events + var agentEvents = events.OfType().ToList(); + agentEvents.Should().NotBeEmpty("agent should have executed and produced update events"); + + // Check that we have output events + var outputEvents = events.OfType().ToList(); + outputEvents.Should().NotBeEmpty("workflow should produce output events"); + } + /// /// Simple test agent that echoes back the input message. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs new file mode 100644 index 0000000000..77c0160200 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public sealed class InputWaiterTests : IDisposable +{ + private readonly InputWaiter _waiter = new(); + + public void Dispose() + { + this._waiter.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_CompletesAfterSignalAsync() + { + this._waiter.SignalInput(); + + // WaitForInputAsync should complete immediately since input was already signaled + Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None); + Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); + + completed.Should().BeSameAs(waitTask, "the wait task should complete before the timeout"); + await waitTask; + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync() + { + Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5)); + + await Task.Delay(50); + waitTask.IsCompleted.Should().BeFalse("the waiter should block until input is signaled"); + + this._waiter.SignalInput(); + + Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); + completed.Should().BeSameAs(waitTask, "the wait task should complete after being signaled"); + await waitTask; + } + + [Fact] + public void InputWaiter_SignalInput_DoubleSignalDoesNotThrow() + { + // Binary semaphore behavior: double signal should be idempotent + FluentActions.Invoking(() => + { + this._waiter.SignalInput(); + this._waiter.SignalInput(); + }).Should().NotThrow("double signaling should be handled gracefully"); + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_RespectsCancellationAsync() + { + using CancellationTokenSource cts = new(); + Task waitTask = this._waiter.WaitForInputAsync(cts.Token); + + cts.Cancel(); + + Func act = () => waitTask; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_DoesNotCompleteWhenNotSignaledAsync() + { + using CancellationTokenSource cts = new(); + Task waitTask = this._waiter.WaitForInputAsync(cts.Token); + Task completed = await Task.WhenAny(waitTask, Task.Delay(100)); + + completed.Should().NotBeSameAs(waitTask, "the wait task should not complete when input is not signaled"); + + // Cancel and observe the pending task to avoid an unobserved exception on Dispose + cts.Cancel(); + try { await waitTask; } + catch (OperationCanceledException) { } + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_CanBeSignaledMultipleTimesSequentiallyAsync() + { + // First signal/wait cycle + this._waiter.SignalInput(); + await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); + + // Second signal/wait cycle + this._waiter.SignalInput(); + await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); + } +} + +public class OutputFilterTests +{ + private static OutputFilter CreateFilterWithOutputFrom(string outputExecutorId) + { + NoOpExecutor start = new("start"); + NoOpExecutor end = new("end"); + + Workflow workflow = new WorkflowBuilder("start") + .AddEdge(start, end) + .WithOutputFrom(outputExecutorId == "end" ? end : start) + .Build(); + + return new OutputFilter(workflow); + } + + [Fact] + public void OutputFilter_CanOutput_ReturnsTrueForRegisteredExecutor() + { + OutputFilter filter = CreateFilterWithOutputFrom("end"); + + filter.CanOutput("end", "some output").Should().BeTrue("the executor was registered via WithOutputFrom"); + } + + [Fact] + public void OutputFilter_CanOutput_ReturnsFalseForUnregisteredExecutor() + { + OutputFilter filter = CreateFilterWithOutputFrom("end"); + + filter.CanOutput("start", "some output").Should().BeFalse("start was not registered as an output executor"); + } + + [Fact] + public void OutputFilter_CanOutput_ReturnsFalseForNonExistentExecutor() + { + OutputFilter filter = CreateFilterWithOutputFrom("end"); + + filter.CanOutput("nonexistent", "some output").Should().BeFalse("an executor not in the workflow should not be an output executor"); + } + + private sealed class NoOpExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => + routeBuilder.AddHandler((msg, ctx) => ctx.SendMessageAsync(msg))); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj index 58979a4f1b..22764bb163 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj @@ -1,7 +1,7 @@  - $(NoWarn);MEAI001 + $(NoWarn);MEAI001;MAAIW001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoundRobinGroupChatManagerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoundRobinGroupChatManagerTests.cs new file mode 100644 index 0000000000..3c87507ec6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoundRobinGroupChatManagerTests.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class RoundRobinGroupChatManagerTests +{ + [Fact] + public async Task RoundRobinGroupChat_SelectNextAgent_CyclesInOrderAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + TestEchoAgent agent2 = new(id: "agent2"); + TestEchoAgent agent3 = new(id: "agent3"); + List agents = [agent1, agent2, agent3]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents); + + AIAgent first = await manager.SelectNextAgentAsync(history); + AIAgent second = await manager.SelectNextAgentAsync(history); + AIAgent third = await manager.SelectNextAgentAsync(history); + + first.Should().BeSameAs(agent1); + second.Should().BeSameAs(agent2); + third.Should().BeSameAs(agent3); + } + + [Fact] + public async Task RoundRobinGroupChat_SelectNextAgent_WrapsAroundAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + TestEchoAgent agent2 = new(id: "agent2"); + List agents = [agent1, agent2]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents); + + await manager.SelectNextAgentAsync(history); + await manager.SelectNextAgentAsync(history); + + AIAgent wrappedAgent = await manager.SelectNextAgentAsync(history); + + wrappedAgent.Should().BeSameAs(agent1, "the manager should wrap around to the first agent after cycling through all agents"); + } + + [Fact] + public async Task RoundRobinGroupChat_ShouldTerminate_DefaultBehaviorTerminatesAtMaxIterationsAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + List agents = [agent1]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents) { MaximumIterationCount = 3 }; + + manager.IterationCount = 2; + bool shouldTerminateBefore = await manager.ShouldTerminateAsync(history); + shouldTerminateBefore.Should().BeFalse("the iteration count has not yet reached the maximum"); + + manager.IterationCount = 3; + bool shouldTerminateAt = await manager.ShouldTerminateAsync(history); + shouldTerminateAt.Should().BeTrue("the iteration count has reached the maximum"); + } + + [Fact] + public async Task RoundRobinGroupChat_ShouldTerminate_CustomFuncTerminatesEarlyAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + List agents = [agent1]; + List history = [new ChatMessage(ChatRole.Assistant, "done")]; + + RoundRobinGroupChatManager manager = new(agents, + shouldTerminateFunc: (_, messages, _) => new(messages.Any(m => m.Text == "done"))) + { + MaximumIterationCount = 100 + }; + + bool shouldTerminate = await manager.ShouldTerminateAsync(history); + shouldTerminate.Should().BeTrue("the custom termination function should cause early termination"); + } + + [Fact] + public async Task RoundRobinGroupChat_ShouldTerminate_CustomFuncDoesNotTerminateWhenNotMetAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + List agents = [agent1]; + List history = [new ChatMessage(ChatRole.Assistant, "continue")]; + + RoundRobinGroupChatManager manager = new(agents, + shouldTerminateFunc: (_, messages, _) => new(messages.Any(m => m.Text == "done"))) + { + MaximumIterationCount = 100 + }; + + bool shouldTerminate = await manager.ShouldTerminateAsync(history); + shouldTerminate.Should().BeFalse("the custom termination function should not cause termination when condition is not met"); + } + + [Fact] + public async Task RoundRobinGroupChat_Reset_ResetsIterationCountAndAgentIndexAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + TestEchoAgent agent2 = new(id: "agent2"); + List agents = [agent1, agent2]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents); + manager.IterationCount = 5; + + // Advance the internal index past the first agent + await manager.SelectNextAgentAsync(history); + + manager.Reset(); + + manager.IterationCount.Should().Be(0, "Reset should clear the iteration count"); + + AIAgent afterReset = await manager.SelectNextAgentAsync(history); + afterReset.Should().BeSameAs(agent1, "Reset should cause the next selection to start from the first agent"); + } + + [Fact] + public void RoundRobinGroupChat_Constructor_ThrowsOnNullAgents() + { + FluentActions.Invoking(() => new RoundRobinGroupChatManager(null!)) + .Should().Throw() + .WithParameterName("agents"); + } + + [Fact] + public void RoundRobinGroupChat_Constructor_ThrowsOnEmptyAgents() + { + FluentActions.Invoking(() => new RoundRobinGroupChatManager([])) + .Should().Throw(); + } +} diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs deleted file mode 100644 index 9441d9534b..0000000000 --- a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable CS0618 // Type or member is obsolete - Testing deprecated OpenAI Assistants API extension methods - -using System; -using System.Diagnostics; -using System.IO; -using System.Threading.Tasks; -using AgentConformance.IntegrationTests.Support; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using OpenAI; -using OpenAI.Assistants; -using OpenAI.Files; -using OpenAI.VectorStores; -using Shared.IntegrationTests; - -namespace OpenAIAssistant.IntegrationTests; - -public class OpenAIAssistantClientExtensionsTests -{ - private const string SkipCodeInterpreterReason = "OpenAI Assistant Code Interpreter intermittently fails in CI"; - - private readonly AssistantClient _assistantClient = new OpenAIClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIApiKey)).GetAssistantClient(); - private readonly OpenAIFileClient _fileClient = new OpenAIClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIApiKey)).GetOpenAIFileClient(); - - [Theory] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithChatClientAgentOptionsSync")] - [InlineData("CreateWithParamsAsync")] - public async Task CreateAIAgentAsync_WithAIFunctionTool_InvokesFunctionAsync(string createMechanism) - { - // Arrange - const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; - - static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; - var weatherFunction = AIFunctionFactory.Create(GetWeather, nameof(GetWeather)); - - // Act - var agent = createMechanism switch - { - "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - options: new ChatClientAgentOptions() - { - ChatOptions = new() - { - Instructions = AgentInstructions, - Tools = [weatherFunction] - } - }), - "CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - options: new ChatClientAgentOptions() - { - ChatOptions = new() - { - Instructions = AgentInstructions, - Tools = [weatherFunction] - } - }), - "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - instructions: AgentInstructions, - tools: [weatherFunction]), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") - }; - - try - { - // Trigger function call. - var response = await agent.RunAsync("What is the weather like in Amsterdam?"); - var text = response.Text; - - // Assert - Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase); - } - finally - { - await this._assistantClient.DeleteAssistantAsync(agent.Id); - } - } - - [Theory(Skip = SkipCodeInterpreterReason)] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithChatClientAgentOptionsSync")] - [InlineData("CreateWithParamsAsync")] - public async Task CreateAIAgentAsync_WithHostedCodeInterpreter_RunsCodeAsync(string createMechanism) - { - // Arrange - const string Instructions = "Use the Code Interpreter Tool to run the uploaded python file and respond only with the secret number."; - - // Create a python file that prints a known value. - var codeFilePath = Path.GetTempFileName() + "openai_secret_number.py"; - File.WriteAllText( - path: codeFilePath, - contents: "print(\"OPENAI_SECRET=13579\")" // Deterministic output we will look for. - ); - - // Upload file to OpenAI Assistants file store for use with the Code Interpreter. - var uploadResult = await this._fileClient.UploadFileAsync(codeFilePath, FileUploadPurpose.Assistants); - string uploadedFileId = uploadResult.Value.Id; - var codeInterpreterTool = new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedFileId)] }; - - var agent = createMechanism switch - { - "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - options: new ChatClientAgentOptions() - { - ChatOptions = new() - { - Instructions = Instructions, - Tools = [codeInterpreterTool] - } - }), - "CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - options: new ChatClientAgentOptions() - { - ChatOptions = new() - { - Instructions = Instructions, - Tools = [codeInterpreterTool] - } - }), - "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - instructions: Instructions, - tools: [codeInterpreterTool]), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") - }; - - try - { - var response = await agent.RunAsync("What is the OPENAI_SECRET number?"); - var text = response.ToString(); - Assert.Contains("13579", text); - } - finally - { - await this._assistantClient.DeleteAssistantAsync(agent.Id); - await this._fileClient.DeleteFileAsync(uploadedFileId); - File.Delete(codeFilePath); - } - } - - [Theory(Skip = "For manual testing only")] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithChatClientAgentOptionsSync")] - [InlineData("CreateWithParamsAsync")] - public async Task CreateAIAgentAsync_WithHostedFileSearchTool_SearchesFilesAsync(string createMechanism) - { - // Arrange. - const string Instructions = """ - You are a helpful agent that can help fetch data from files you know about. - Use the File Search Tool to look up codes for words. - Do not answer a question unless you can find the answer using the File Search Tool. - """; - - // Create a local file with deterministic content and upload it. - var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt"; - File.WriteAllText( - path: searchFilePath, - contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."); - var uploadResult = await this._fileClient.UploadFileAsync(searchFilePath, FileUploadPurpose.Assistants); - string uploadedFileId = uploadResult.Value.Id; - - // Create a vector store backing the file search (HostedFileSearchTool requires a vector store id). - var vectorStoreClient = new OpenAIClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIApiKey)).GetVectorStoreClient(); - var vectorStoreCreate = await vectorStoreClient.CreateVectorStoreAsync(options: new VectorStoreCreationOptions() - { - Name = "WordCodeLookup_VectorStore", - FileIds = { uploadedFileId } - }); - string vectorStoreId = vectorStoreCreate.Value.Id; - - // Wait for vector store indexing to complete before using it - await WaitForVectorStoreReadyAsync(vectorStoreClient, vectorStoreId); - - var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }; - - var agent = createMechanism switch - { - "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - options: new ChatClientAgentOptions() - { - ChatOptions = new() - { - Instructions = Instructions, - Tools = [fileSearchTool] - } - }), - "CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - options: new ChatClientAgentOptions() - { - ChatOptions = new() - { - Instructions = Instructions, - Tools = [fileSearchTool] - } - }), - "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName), - instructions: Instructions, - tools: [fileSearchTool]), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") - }; - - try - { - // Act - ask about banana code which must be retrieved via file search. - var response = await agent.RunAsync("Can you give me the documented code for 'banana'?"); - var text = response.ToString(); - Assert.Contains("673457", text); - } - finally - { - await this._assistantClient.DeleteAssistantAsync(agent.Id); - await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId); - await this._fileClient.DeleteFileAsync(uploadedFileId); - File.Delete(searchFilePath); - } - } - - /// - /// Waits for a vector store to complete indexing by polling its status. - /// - /// The vector store client. - /// The ID of the vector store. - /// Maximum time to wait in seconds (default: 30). - /// A task that completes when the vector store is ready or throws on timeout/failure. - private static async Task WaitForVectorStoreReadyAsync( - VectorStoreClient client, - string vectorStoreId, - int maxWaitSeconds = 30) - { - Stopwatch sw = Stopwatch.StartNew(); - while (sw.Elapsed.TotalSeconds < maxWaitSeconds) - { - VectorStore vectorStore = await client.GetVectorStoreAsync(vectorStoreId); - VectorStoreStatus status = vectorStore.Status; - - if (status == VectorStoreStatus.Completed) - { - if (vectorStore.FileCounts.Failed > 0) - { - throw new InvalidOperationException("Vector store indexing failed for some files"); - } - - return; - } - - if (status == VectorStoreStatus.Expired) - { - throw new InvalidOperationException("Vector store has expired"); - } - - await Task.Delay(1000); - } - - throw new TimeoutException($"Vector store did not complete indexing within {maxWaitSeconds}s"); - } -} diff --git a/python/.env.example b/python/.env.example index 4e7ba727e5..e8644ea003 100644 --- a/python/.env.example +++ b/python/.env.example @@ -1,6 +1,15 @@ -# Azure AI +# Microsoft Foundry FOUNDRY_PROJECT_ENDPOINT="" +# Model used for FoundryChatClient FOUNDRY_MODEL="" +# Foundry Agents (prompt or hosted agents) +FOUNDRY_AGENT_NAME="" +FOUNDRY_AGENT_VERSION="" +# Microsoft Foundry Models endpoint, used by embeddings +FOUNDRY_MODELS_ENDPOINT="" +FOUNDRY_MODELS_API_KEY="" +FOUNDRY_EMBEDDING_MODEL="" +FOUNDRY_IMAGE_EMBEDDING_MODEL="" # Bing connection for web search (optional, used by samples with web search) BING_CONNECTION_ID="" # Azure AI Search (optional, used by AzureAISearchContextProvider samples) @@ -13,12 +22,12 @@ AZURE_SEARCH_KNOWLEDGE_BASE_NAME="" # (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls) # OpenAI OPENAI_API_KEY="" +OPENAI_CHAT_COMPLETION_MODEL="" OPENAI_CHAT_MODEL="" -OPENAI_RESPONSES_MODEL="" # Azure OpenAI AZURE_OPENAI_ENDPOINT="" -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="" -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="" +AZURE_OPENAI_CHAT_COMPLETION_MODEL="" +AZURE_OPENAI_CHAT_MODEL="" # Mem0 MEM0_API_KEY="" # Copilot Studio diff --git a/python/.github/skills/python-development/SKILL.md b/python/.github/skills/python-development/SKILL.md index ca73bd8ada..d3bb38ca4b 100644 --- a/python/.github/skills/python-development/SKILL.md +++ b/python/.github/skills/python-development/SKILL.md @@ -76,7 +76,7 @@ from agent_framework.observability import enable_instrumentation # Connectors (lazy-loaded) from agent_framework.openai import OpenAIChatClient -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient ``` ## Public API and Exports diff --git a/python/.github/skills/python-feature-lifecycle/SKILL.md b/python/.github/skills/python-feature-lifecycle/SKILL.md new file mode 100644 index 0000000000..d9b654a9da --- /dev/null +++ b/python/.github/skills/python-feature-lifecycle/SKILL.md @@ -0,0 +1,238 @@ +# Copyright (c) Microsoft. All rights reserved. + +--- +name: python-feature-lifecycle +description: > + Guidance for package and feature lifecycle in the Agent Framework Python + codebase, including stage meanings, feature-stage decorators, feature enums, + and how to move APIs from one stage to the next. +--- + +# Python Feature Lifecycle + +## Two lifecycle levels + +Agent Framework uses lifecycle at two different levels: + +1. **Package lifecycle** — the maturity of the package as a whole +2. **Feature lifecycle** — the maturity of a specific API or feature inside that package + +These are related, but they are **not the same thing**. + +- The **package stage is the default** for everything in the package. +- **Feature-stage decorators are only for exceptions** when a feature is behind the package's default stage. +- Do **not** decorate every class or function just because the package is experimental or release candidate. + +### Important default + +If a package is still in **beta / experimental preview**, all public APIs in that package are experimental by default. + +- Do **not** add `@experimental(...)` everywhere in that package. +- The package stage already communicates that default. + +Once a package moves forward, you can keep individual features behind: + +- If a package moves to **release candidate**, a feature may remain **experimental** +- If a package moves to **released / GA**, a feature may remain **experimental** or **release candidate** + +That is the main use case for feature-stage decorators. + +## The four stages + +### 1. Experimental + +Use for features that are still unstable and may change or be removed without notice. + +Feature-level code pattern: + +```python +from ._feature_stage import ExperimentalFeature, experimental + + +@experimental(feature_id=ExperimentalFeature.MY_FEATURE) +class MyFeature: + ... +``` + +Behavior: + +- Adds an experimental warning block to the docstring +- Records feature metadata on the decorated object +- Emits a runtime warning the first time the feature is used (once per feature by default) + +Enum setup: + +- Add an all-caps member to `ExperimentalFeature` +- Reuse the same feature ID across all APIs that belong to the same conceptual feature + +### 2. Release candidate + +Use for features that are nearly stable but may still receive small refinements before GA. + +Feature-level code pattern: + +```python +from ._feature_stage import ReleaseCandidateFeature, release_candidate + + +@release_candidate(feature_id=ReleaseCandidateFeature.MY_FEATURE) +class MyFeature: + ... +``` + +Behavior: + +- Adds a release-candidate note to the docstring +- Records feature metadata on the decorated object +- Does **not** emit the experimental warning + +Enum setup: + +- Add an all-caps member to `ReleaseCandidateFeature` + +### 3. Released + +Use for stable GA APIs. + +Code pattern: + +- **No feature-stage decorator** +- **No entry** in `ExperimentalFeature` +- **No entry** in `ReleaseCandidateFeature` + +If a feature is fully released, remove any stage-specific feature annotation. + +### 4. Deprecated + +Use for APIs that still exist but should not be used for new code. + +Code pattern: + +```python +import sys + +if sys.version_info >= (3, 13): + from warnings import deprecated # type: ignore # pragma: no cover +else: + from typing_extensions import deprecated # type: ignore # pragma: no cover + + +@deprecated("MyOldFeature is deprecated. Use MyNewFeature instead.") +class MyOldFeature: + ... +``` + +Behavior: + +- Uses the repository's version-conditional deprecation import pattern +- Should describe what to use instead + +Deprecated APIs should not also carry feature-stage decorators. + +## Expected decorators by stage + +| Feature stage | Expected annotation | +| --- | --- | +| Experimental | `@experimental(feature_id=ExperimentalFeature.X)` | +| Release candidate | `@release_candidate(feature_id=ReleaseCandidateFeature.X)` | +| Released | No feature-stage decorator | +| Deprecated | `@deprecated("...")` | + +## Feature enums + +The feature enums are the inventory of currently staged features: + +- `ExperimentalFeature` +- `ReleaseCandidateFeature` + +Guidance: + +- Use one enum member per conceptual feature, not per class +- Ideally, an ADR already defines the overall feature boundary and therefore the feature ID that staged APIs for that feature should reuse +- Keep feature IDs all caps +- Reuse the same member across related APIs for the same feature +- Remove enum members when the feature no longer belongs to that stage +- Treat these enums as **current-stage inventories**, not as a stable consumer introspection API + +Minimal consumer guidance: + +- Treat `__feature_stage__` and `__feature_id__` as optional staged metadata, not as stable contracts +- Use `getattr(obj, "__feature_stage__", None)` and `getattr(obj, "__feature_id__", None)` rather than direct attribute access +- Treat missing metadata as "no explicit feature-stage annotation" +- For warning filters while a feature is staged, match the literal feature ID string +- Do **not** rely on `ExperimentalFeature.X`, `ReleaseCandidateFeature.X`, or the continued presence of `__feature_id__` after a feature moves stages or is released + +For consumers, the enums are also re-exported from `agent_framework`. + +For internal implementation code inside `agent_framework`, continue to import the enums and decorators from `._feature_stage`. + +## Package stage vs feature stage + +Use the following rules: + +### Package is experimental / beta + +- All public APIs are experimental by default +- Do **not** add feature-stage decorators just to restate that +- Only introduce feature-level annotations later if the package advances first + +### Package is release candidate + +- All public APIs are RC by default +- Do **not** decorate everything +- Add `@experimental(...)` only for features that are intentionally still behind the package + +### Package is released / GA + +- All public APIs are released by default +- Add `@experimental(...)` or `@release_candidate(...)` only for features still being held back + +## Moving a feature from one stage to the next + +### Experimental -> Release candidate + +1. Move the feature ID from `ExperimentalFeature` to `ReleaseCandidateFeature` +2. Replace `@experimental(...)` with `@release_candidate(...)` +3. Update any tests or docs that mention the old stage + +### Experimental -> Released + +1. Remove `@experimental(...)` +2. Remove the feature from `ExperimentalFeature` +3. Do not add a replacement feature-stage decorator + +### Release candidate -> Released + +1. Remove `@release_candidate(...)` +2. Remove the feature from `ReleaseCandidateFeature` +3. Leave the API undecorated + +### Any stage -> Deprecated + +1. Remove any feature-stage decorator +2. Remove the feature from the stage enum +3. Add `@deprecated("...")` +4. Update docs/tests to reflect the replacement path + +## Promotion guidance + +Features do **not** have to pass through every stage. + +- It is usually a good idea to move features in order when that reflects reality +- But it is completely acceptable to go **experimental -> released** +- Do **not** force a feature through release candidate if there is no real RC period + +Likewise, when a package advances, do not automatically move every feature with it. + +- Promote features based on actual readiness +- Keep lagging features explicitly marked only when they are behind the package default + +## Practical rules of thumb + +- **Package default first, feature exceptions second** +- **Do not decorate everything in preview packages** +- **Do not double-annotate members of an already-staged class** +- **Use enums only for currently staged features** +- **Do not treat stage enums as a compatibility contract** +- **Treat `__feature_stage__` and `__feature_id__` as optional metadata; use `getattr`** +- **Remove stage annotations once a feature is released or deprecated** diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index 814410e73d..258112184a 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -15,7 +15,7 @@ python/ ├── pyproject.toml # Root package (agent-framework) ├── packages/ │ ├── core/ # agent-framework-core (main package) -│ ├── azure-ai/ # agent-framework-azure-ai +│ ├── foundry/ # agent-framework-foundry │ ├── anthropic/ # agent-framework-anthropic │ └── ... # Other connector packages ``` @@ -76,9 +76,9 @@ uv run poe add-dependency-and-validate-bounds --package core --dependency " Any: @@ -97,13 +97,20 @@ def __getattr__(name: str) -> Any: **Important:** Do not create a new package unless approved by the core team. -### Initial Release (Preview) +Every new package starts as `alpha`. + +### Alpha package checklist 1. Create directory under `packages/` (e.g., `packages/my-connector/`) 2. Add the package to `tool.uv.sources` in root `pyproject.toml` -3. Include samples inside the package (e.g., `packages/my-connector/samples/`) -4. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml` -5. Do **NOT** create lazy loading in core yet +3. Set the package version to the alpha pattern: `1.0.0a` +4. Set the package classifier to `Development Status :: 3 - Alpha` +5. Include samples inside the package (e.g., `packages/my-connector/samples/`) +6. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml` +7. Do **NOT** create lazy loading in core yet +8. Add the package to `python/PACKAGE_STATUS.md` and keep that file updated when packages are added, + removed, renamed, or promoted. If the package exposes individually staged APIs, keep the feature list + there current too. Recommended dependency workflow during connector implementation: @@ -116,17 +123,83 @@ Recommended dependency workflow during connector implementation: `uv run poe add-dependency-and-validate-bounds --package core --dependency ""` If compatibility checks are not in place yet, add the dependency first, then implement tests before running bound validation. -### Promotion to Stable +### Promotion path -1. Move samples to root `samples/` folder -2. Add to `[all]` extra in `packages/core/pyproject.toml` -3. Create provider folder in `agent_framework/` with lazy loading `__init__.py` +Promotion work is not isolated to the package being promoted. If a promotion changes dependency +metadata for downstream packages, also update the dependent packages' own versions so they publish +new metadata alongside the promoted dependency bounds. +Apply the internal package dependency update rules from the versioning section below during +promotions as well as standalone version update work. + +#### Alpha -> Beta + +Move a package to `beta` when it is stable enough to be part of the main install surface. + +1. Update the package version to the beta pattern: `1.0.0b` +2. Update the classifier to `Development Status :: 4 - Beta` +3. Add the package to `[all]` in `packages/core/pyproject.toml` +4. Move samples to the root `samples/` tree and remove package-local samples +5. Create or update the relevant lazy-loading namespace in core when the package belongs under one +6. Update `python/PACKAGE_STATUS.md` + +After `alpha`, there should be no samples left inside a package folder. + +#### Beta -> RC + +Move a package to `rc` when its API is close to the final released shape. + +1. Update the package version to the release-candidate pattern: `1.0.0rc` +2. Keep the classifier at `Development Status :: 4 - Beta` because PyPI does not have a separate + release-candidate classifier +3. Keep the package in `core[all]` +4. Keep samples only in the root `samples/` tree +5. Update `python/PACKAGE_STATUS.md` to show the package as `rc` + +#### RC -> Released + +Move a package to `released` when it no longer carries a prerelease qualifier. + +1. Update the package version to the stable pattern: `1.0.0` +2. Update the classifier to `Development Status :: 5 - Production/Stable` +3. Keep the package in `core[all]` +4. Keep samples only in the root `samples/` tree +5. Update `python/PACKAGE_STATUS.md` to show the package as `released` +6. Update all `README.md` files that install that package with + `pip install agent-framework-... --pre` so they use `pip install agent-framework-...` without + the `--pre` suffix ## Versioning +### Internal package dependency updates + +- If package A depends on package B within this repository, only update package A's dependency + declaration when the work on package B actually affects package A. +- If package A does not need anything from the package B change, leave package A's dependency + declaration unchanged. +- If package A does need something from the package B change, update package A's dependency + declaration to the version or versioning scheme that matches what package A now requires. +- If package B is promoted to a different lifecycle stage, update package A's dependency + declaration to the new versioning scheme for package B even when the only change is the stage + transition itself. +- Use this guidance both for ordinary version updates and for package promotion work. + - All non-core packages declare a lower bound on `agent-framework-core` - When core version bumps with breaking changes, update the lower bound in all packages - Non-core packages version independently; only raise core bound when using new core APIs +- If promoting a package changes a dependent package's published dependency metadata, bump the + dependent package's own version in the correct lifecycle pattern for its current stage +- Lifecycle version patterns: + - `alpha`: `1.0.0a` + - `beta`: `1.0.0b` + - `rc`: `1.0.0rc` + - `released`: `1.0.0` +- Keep the `Development Status` classifier in `pyproject.toml` aligned with the lifecycle stage: + - `alpha` -> `Development Status :: 3 - Alpha` + - `beta` -> `Development Status :: 4 - Beta` + - `rc` -> `Development Status :: 4 - Beta` + - `released` -> `Development Status :: 5 - Production/Stable` +- See the PyPI classifier list for the available classifier values: + `https://pypi.org/classifiers/` ## Installation Options @@ -134,7 +207,7 @@ Recommended dependency workflow during connector implementation: pip install agent-framework-core # Core only pip install agent-framework-core[all] # Core + all connectors pip install agent-framework # Same as core[all] -pip install agent-framework-azure-ai # Specific connector (pulls in core) +pip install agent-framework-foundry # Specific connector (pulls in core) ``` ## Maintaining Documentation @@ -143,3 +216,15 @@ When changing a package, check if its `AGENTS.md` needs updates: - Adding/removing/renaming public classes or functions - Changing the package's purpose or architecture - Modifying import paths or usage patterns + +Keep `python/PACKAGE_STATUS.md` updated when: +- A package is added, removed, renamed, or promoted between lifecycle stages +- A package starts or stops exposing individually staged experimental or release-candidate APIs + +When a package adds, removes, or renames environment variables, update the related documentation in the same +change: +- The package's `README.md` for package-level configuration/env var guidance +- `samples/README.md` if the package is included in `packages/core/pyproject.toml` `[all]` and the env var is + part of the consolidated package env-var inventory +- Any affected sample/package-local `.env.example`, `.env.template`, or sample README files when sample setup + changes alongside the package diff --git a/python/.github/skills/python-testing/SKILL.md b/python/.github/skills/python-testing/SKILL.md index b9c874a694..1d8fb50cf1 100644 --- a/python/.github/skills/python-testing/SKILL.md +++ b/python/.github/skills/python-testing/SKILL.md @@ -124,7 +124,7 @@ The merge CI workflow (`python-merge-tests.yml`) splits integration tests into p - **Azure OpenAI integration** — runs when `packages/core/agent_framework/azure/` or core changes - **Misc integration** — Anthropic, Ollama, MCP tests; runs when their packages or core change - **Functions integration** — Azure Functions + Durable Task; runs when their packages or core change -- **Azure AI integration** — runs when `packages/azure-ai/` or core changes +- **Foundry integration** — runs when `packages/foundry/` or core changes Core infrastructure changes (e.g., `_agents.py`, `_types.py`) trigger all integration test jobs. Scheduled and manual runs always execute all jobs. diff --git a/python/AGENTS.md b/python/AGENTS.md index 7ec268dcd1..e4697e18d5 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -11,6 +11,7 @@ Instructions for AI coding agents working in the Python codebase. - `python-development` — coding standards, type annotations, docstrings, logging, performance - `python-testing` — test structure, fixtures, async mode, running tests - `python-code-quality` — linting, formatting, type checking, prek hooks, CI workflow +- `python-feature-lifecycle` — package vs feature lifecycle stages, decorators, enums, and promotion guidance - `python-package-management` — monorepo structure, lazy loading, versioning, new packages - `python-samples` — sample file structure, PEP 723, documentation guidelines @@ -39,7 +40,7 @@ python/ │ ├── core/ # agent-framework-core (main package) │ │ ├── agent_framework/ # Public API exports │ │ └── tests/ -│ ├── azure-ai/ # agent-framework-azure-ai +│ ├── foundry/ # agent-framework-foundry │ ├── anthropic/ # agent-framework-anthropic │ ├── ollama/ # agent-framework-ollama │ └── ... # Other provider packages @@ -51,7 +52,7 @@ python/ ### Package Relationships - `agent-framework-core` contains core abstractions and OpenAI/Azure OpenAI built-in -- Provider packages (`azure-ai`, `anthropic`, etc.) extend core with specific integrations +- Provider packages (`foundry`, `anthropic`, etc.) extend core with specific integrations - Core uses lazy loading via `__getattr__` in provider folders (e.g., `agent_framework/azure/`) ## Package Documentation @@ -67,8 +68,9 @@ python/ - [ollama](packages/ollama/AGENTS.md) - Local Ollama inference ### Azure Integrations -- [azure-ai](packages/azure-ai/AGENTS.md) - Azure AI Foundry agents +- [foundry](packages/foundry/README.md) - Microsoft Foundry chat, agent, memory, and embedding integrations - [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 ### Protocols & UI diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index d02b22e088..8c73414f3f 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -192,7 +192,7 @@ The package follows a flat import structure: - **Connectors**: Import from `agent_framework.` ```python from agent_framework.openai import OpenAIChatClient - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.foundry import FoundryChatClient ``` ## Exception Hierarchy @@ -325,14 +325,15 @@ python/ │ │ ├── mem0/ # Lazy loads from agent-framework-mem0 │ │ └── redis/ # Lazy loads from agent-framework-redis │ │ -│ ├── azure-ai/ # agent-framework-azure-ai +│ ├── foundry/ # agent-framework-foundry │ │ ├── pyproject.toml │ │ ├── tests/ -│ │ └── agent_framework_azure_ai/ +│ │ └── agent_framework_foundry/ │ │ ├── __init__.py # Public exports -│ │ ├── _chat_client.py # AzureAIClient implementation -│ │ ├── _client.py # AzureAIAgentClient implementation -│ │ ├── _shared.py # AzureAISettings and shared utilities +│ │ ├── _chat_client.py # FoundryChatClient implementation +│ │ ├── _agent.py # FoundryAgent implementation +│ │ ├── _embedding_client.py # FoundryEmbeddingClient implementation +│ │ ├── _memory_provider.py # Foundry memory implementation │ │ └── py.typed # PEP 561 marker │ ├── anthropic/ # agent-framework-anthropic │ ├── bedrock/ # agent-framework-bedrock @@ -345,9 +346,9 @@ python/ Provider folders in the core package use `__getattr__` to lazy load classes from their respective connector packages. This allows users to import from a consistent location while only loading dependencies when needed: ```python -# In agent_framework/azure/__init__.py +# In agent_framework/foundry/__init__.py _IMPORTS: dict[str, tuple[str, str]] = { - "AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), # ... } @@ -419,7 +420,7 @@ pip install agent-framework-core[all] pip install agent-framework # Install specific connector (pulls in core as dependency) -pip install agent-framework-azure-ai +pip install agent-framework-foundry ``` ## Documentation @@ -429,6 +430,10 @@ Each file should have a single first line containing: # Copyright (c) Microsoft. We follow the [Google Docstring](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods) style guide for functions and methods. They are currently not checked for private functions (functions starting with '_'). +When a change adds, removes, or renames a sample-facing environment variable in repo-level samples or +package-local sample docs for a package included by `agent-framework-core[all]`, update the consolidated +inventory in `samples/README.md` in the same change. + They should contain: - Single line explaining what the function does, ending with a period. @@ -476,7 +481,7 @@ A more complete example with keyword arguments and code samples: ```python def create_client( - model_id: str | None = None, + model: str | None = None, *, timeout: float | None = None, env_file_path: str | None = None, @@ -485,7 +490,7 @@ def create_client( """Create a new client with the specified configuration. Args: - model_id: The model ID to use. If not provided, + model: The model ID to use. If not provided, it will be loaded from settings. Keyword Args: @@ -497,14 +502,14 @@ def create_client( A configured client instance. Raises: - ValueError: If the model_id is invalid. + ValueError: If the model is invalid. Examples: .. code-block:: python # Create a client with default settings: - client = create_client(model_id="gpt-4o") + client = create_client(model="gpt-4o") # Or load from environment: client = create_client(env_file_path=".env") diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index dbddbaac93..f2c899ed75 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -58,7 +58,7 @@ You can then run the following commands manually: # Install Python 3.10, 3.11, 3.12, and 3.13 uv python install 3.10 3.11 3.12 3.13 # Create a virtual environment with Python 3.10 (you can change this to 3.11, 3.12 or 3.13) -$PYTHON_VERSION = "3.10" +PYTHON_VERSION="3.10" uv venv --python $PYTHON_VERSION # Install AF and all dependencies uv sync --dev @@ -180,7 +180,7 @@ This will show you which files are not covered by the tests, including the speci ## Catching up with the latest changes -There are many people committing to Semantic Kernel, so it is important to keep your local repository up to date. To do this, you can run the following commands: +There are many people committing to Agent Framework, so it is important to keep your local repository up to date. To do this, you can run the following commands: ```bash git fetch upstream main diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md new file mode 100644 index 0000000000..7a726812ff --- /dev/null +++ b/python/PACKAGE_STATUS.md @@ -0,0 +1,71 @@ +# Python Package Status + +This file tracks the current lifecycle state of the Python packages in this workspace. Some packages at later stages might have features within them that are not ready yet, these have feature stage decorators on the relevant APIs, and for `experimental` features warnings are raised. See the [Feature-level staged APIs](#feature-level-staged-apis) section below for details on which features are in which stage and where to find them. + +Status is grouped into these buckets: + +- `alpha` - initial release and early development packages that are not yet ready for general use +- `beta` - prerelease packages that are not currently release candidates +- `rc` - release candidate packages, these are close to ready for release but may still have some breaking changes before the final release +- `released` - stable packages without a prerelease suffix, these are stable packages that should not have breaking changes between versions +- `deprecated` - removed or deprecated packages that should not be used for new work + +## Current packages + +| Package | Path | State | +| --- | --- | --- | +| `agent-framework` | `python/` | `released` | +| `agent-framework-a2a` | `python/packages/a2a` | `beta` | +| `agent-framework-ag-ui` | `python/packages/ag-ui` | `beta` | +| `agent-framework-anthropic` | `python/packages/anthropic` | `beta` | +| `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` | +| `agent-framework-azure-cosmos` | `python/packages/azure-cosmos` | `beta` | +| `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` | +| `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` | +| `agent-framework-core` | `python/packages/core` | `released` | +| `agent-framework-declarative` | `python/packages/declarative` | `beta` | +| `agent-framework-devui` | `python/packages/devui` | `beta` | +| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` | +| `agent-framework-foundry` | `python/packages/foundry` | `released` | +| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | +| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` | +| `agent-framework-lab` | `python/packages/lab` | `beta` | +| `agent-framework-mem0` | `python/packages/mem0` | `beta` | +| `agent-framework-ollama` | `python/packages/ollama` | `beta` | +| `agent-framework-openai` | `python/packages/openai` | `released` | +| `agent-framework-orchestrations` | `python/packages/orchestrations` | `beta` | +| `agent-framework-purview` | `python/packages/purview` | `beta` | +| `agent-framework-redis` | `python/packages/redis` | `beta` | + +## Deprecated / removed packages + +| Package | Previous path | State | Notes | +| --- | --- | --- | --- | +| `agent-framework-azure-ai` | `python/packages/azure-ai` | `deprecated` | The client classes within the `azure-ai` package were renamed, sometimes changed, and moved to `agent-framework-foundry`. | + +## Feature-level staged APIs + +The following feature IDs have explicit feature-stage decorators on public APIs in the packages +listed below. + +### Experimental features + +#### `EVALS` + +- `agent-framework-core`: exported evaluation APIs from `agent_framework`, including + `LocalEvaluator`, `evaluate_agent`, `evaluate_workflow`, and the related evaluation types and + helper checks defined in `agent_framework/_evaluation.py` +- `agent-framework-foundry`: `FoundryEvals`, `evaluate_traces`, and `evaluate_foundry_target` + +#### `SKILLS` + +- `agent-framework-core`: exported skills APIs from `agent_framework`, including `Skill`, + `SkillResource`, `SkillScript`, `SkillScriptRunner`, and `SkillsProvider` from + `agent_framework/_skills.py` + +### Release-candidate features + +There are currently no feature-level `rc` APIs. diff --git a/python/README.md b/python/README.md index 0a3042992f..c721feb8cd 100644 --- a/python/README.md +++ b/python/README.md @@ -9,10 +9,10 @@ We recommend two common installation paths depending on your use case. If you are exploring or developing locally, install the entire framework with all sub-packages: ```bash -pip install agent-framework --pre +pip install agent-framework ``` -This installs the core and every integration package, making sure that all features are available without additional steps. The `--pre` flag is required while Agent Framework is in preview. This is the simplest way to get started. +This installs the core and every integration package, making sure that all features are available without additional steps. This is the simplest way to get started. ### 2. Selective install @@ -22,19 +22,19 @@ If you only need specific integrations, you can install at a more granular level # Core only # includes Azure OpenAI and OpenAI support by default # also includes workflows and orchestrations -pip install agent-framework-core --pre +pip install agent-framework-core # Core + Azure AI Foundry integration -pip install agent-framework-foundry --pre +pip install agent-framework-foundry -# Core + Microsoft Copilot Studio integration +# Core + Microsoft Copilot Studio integration (preview package) pip install agent-framework-copilotstudio --pre # Core + both Microsoft Copilot Studio and Azure AI Foundry integration -pip install agent-framework-microsoft agent-framework-foundry --pre +pip install --pre agent-framework-copilotstudio agent-framework-foundry ``` -This selective approach is useful when you know which integrations you need, and it is the recommended way to set up lightweight environments. +This selective approach is useful when you know which integrations you need, and it is the recommended way to set up lightweight environments. Released packages such as `agent-framework`, `agent-framework-core`, and `agent-framework-foundry` no longer require `--pre`, while preview connectors such as `agent-framework-copilotstudio` still do. Supported Platforms: @@ -51,7 +51,7 @@ OPENAI_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=... +AZURE_OPENAI_MODEL=... ... FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_MODEL=... @@ -249,7 +249,7 @@ For more advanced orchestration patterns including Sequential, Concurrent, Group - [Getting Started with Agents](samples/02-agents): Basic agent creation and tool usage - [Chat Client Examples](samples/02-agents/chat_client): Direct chat client usage patterns -- [Azure AI Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/azure-ai): Azure AI integration +- [Foundry Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/foundry): Microsoft Foundry integration - [Workflow Samples](samples/03-workflows): Advanced multi-agent patterns ## Agent Framework Documentation diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 4b6d9cc19b..9f0ca69163 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -35,9 +35,9 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, BaseAgent, - BaseHistoryProvider, Content, ContinuationToken, + HistoryProvider, Message, ResponseStream, SessionContext, @@ -353,7 +353,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): # Run before_run providers (forward order) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if session is None: raise RuntimeError("Provider session must be available when context providers are configured.") @@ -365,6 +365,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ) all_updates: list[AgentResponseUpdate] = [] + streamed_artifact_ids_by_task: dict[str, set[str]] = {} async for item in a2a_stream: if isinstance(item, A2AMessage): # Process A2A Message @@ -378,12 +379,21 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): all_updates.append(update) yield update elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task): - task, _update_event = item - for update in self._updates_from_task( + task, update_event = item + updates = self._updates_from_task( task, + update_event=update_event, background=background, emit_intermediate=emit_intermediate, + streamed_artifact_ids=streamed_artifact_ids_by_task.get(task.id), + ) + if isinstance(update_event, TaskArtifactUpdateEvent) and any( + update.raw_representation is update_event for update in updates ): + streamed_artifact_ids_by_task.setdefault(task.id, set()).add(update_event.artifact.artifact_id) + if task.status.state in TERMINAL_TASK_STATES: + streamed_artifact_ids_by_task.pop(task.id, None) + for update in updates: all_updates.append(update) yield update else: @@ -403,8 +413,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): self, task: Task, *, + update_event: TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None = None, background: bool = False, emit_intermediate: bool = False, + streamed_artifact_ids: set[str] | None = None, ) -> list[AgentResponseUpdate]: """Convert an A2A Task into AgentResponseUpdate(s). @@ -418,8 +430,21 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): """ status = task.status + if ( + emit_intermediate + and update_event is not None + and (event_updates := self._updates_from_task_update_event(update_event)) + ): + return event_updates + if status.state in TERMINAL_TASK_STATES: task_messages = self._parse_messages_from_task(task) + if task.artifacts is not None and streamed_artifact_ids: + task_messages = [ + message + for message in task_messages + if getattr(message.raw_representation, "artifact_id", None) not in streamed_artifact_ids + ] if task_messages: return [ AgentResponseUpdate( @@ -431,6 +456,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ) for message in task_messages ] + if task.artifacts is not None: + return [] return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)] if background and status.state in IN_PROGRESS_TASK_STATES: @@ -467,6 +494,44 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): return [] + def _updates_from_task_update_event( + self, update_event: TaskStatusUpdateEvent | TaskArtifactUpdateEvent + ) -> list[AgentResponseUpdate]: + """Convert A2A task update events into streaming AgentResponseUpdates.""" + if isinstance(update_event, TaskArtifactUpdateEvent): + contents = self._parse_contents_from_a2a(update_event.artifact.parts) + if not contents: + return [] + return [ + AgentResponseUpdate( + contents=contents, + role="assistant", + response_id=update_event.task_id, + message_id=update_event.artifact.artifact_id, + raw_representation=update_event, + ) + ] + + if not isinstance(update_event, TaskStatusUpdateEvent): + return [] + + message = update_event.status.message + if message is None or not message.parts: + return [] + + contents = self._parse_contents_from_a2a(message.parts) + if not contents: + return [] + + return [ + AgentResponseUpdate( + contents=contents, + role="assistant" if message.role == A2ARole.agent else "user", + response_id=update_event.task_id, + raw_representation=update_event, + ) + ] + @staticmethod def _build_continuation_token(task: Task) -> A2AContinuationToken | None: """Build an A2AContinuationToken from an A2A Task if it is still in progress.""" diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 23b14cd01d..4171f72c4c 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 58a82f6d8c..a0919cbda4 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -14,8 +14,10 @@ from a2a.types import ( FileWithUri, Part, Task, + TaskArtifactUpdateEvent, TaskState, TaskStatus, + TaskStatusUpdateEvent, TextPart, ) from a2a.types import Message as A2AMessage @@ -24,8 +26,8 @@ from agent_framework import ( AgentResponse, AgentResponseUpdate, AgentSession, - BaseContextProvider, Content, + ContextProvider, Message, SessionContext, ) @@ -869,7 +871,7 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A # region Context Provider Tests -class TrackingContextProvider(BaseContextProvider): +class TrackingContextProvider(ContextProvider): """A context provider that records when before_run and after_run are called.""" def __init__(self) -> None: @@ -1189,4 +1191,201 @@ async def test_streaming_working_update_with_empty_parts_is_skipped( assert updates[0].contents[0].text == "Result" +async def test_streaming_artifact_update_event_yields_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that streaming artifact update events yield incremental content.""" + task = Task(id="task-art", context_id="ctx-art", status=TaskStatus(state=TaskState.working, message=None)) + artifact = Artifact( + artifact_id="artifact-1", + parts=[Part(root=TextPart(text="Hello"))], + ) + update_event = TaskArtifactUpdateEvent(task_id="task-art", context_id="ctx-art", artifact=artifact, append=False) + mock_a2a_client.responses.append((task, update_event)) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].text == "Hello" + assert updates[0].message_id == "artifact-1" + assert updates[0].raw_representation == update_event + + +async def test_streaming_status_update_event_yields_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that streaming status update events surface message content directly from the update event.""" + update_event = TaskStatusUpdateEvent( + task_id="task-status", + context_id="ctx-status", + status=TaskStatus( + state=TaskState.working, + message=A2AMessage( + message_id=str(uuid4()), + role=A2ARole.agent, + parts=[Part(root=TextPart(text="Still working"))], + ), + ), + final=False, + ) + task = Task(id="task-status", context_id="ctx-status", status=TaskStatus(state=TaskState.working, message=None)) + mock_a2a_client.responses.append((task, update_event)) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].text == "Still working" + assert updates[0].role == "assistant" + assert updates[0].raw_representation == update_event + + +async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_artifacts( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that streamed artifact chunks are not re-emitted from the final terminal task.""" + working_task = Task(id="task-art-dup", context_id="ctx-art-dup", status=TaskStatus(state=TaskState.working)) + first_chunk = TaskArtifactUpdateEvent( + task_id="task-art-dup", + context_id="ctx-art-dup", + artifact=Artifact( + artifact_id="artifact-dup", + parts=[Part(root=TextPart(text="Hello "))], + ), + append=False, + ) + second_chunk = TaskArtifactUpdateEvent( + task_id="task-art-dup", + context_id="ctx-art-dup", + artifact=Artifact( + artifact_id="artifact-dup", + parts=[Part(root=TextPart(text="world"))], + ), + append=True, + ) + terminal_task = Task( + id="task-art-dup", + context_id="ctx-art-dup", + status=TaskStatus(state=TaskState.completed, message=None), + artifacts=[ + Artifact( + artifact_id="artifact-dup", + parts=[Part(root=TextPart(text="Hello world"))], + ) + ], + ) + terminal_event = TaskStatusUpdateEvent( + task_id="task-art-dup", + context_id="ctx-art-dup", + status=TaskStatus(state=TaskState.completed, message=None), + final=True, + ) + + mock_a2a_client.responses.extend( + [ + (working_task, first_chunk), + (working_task, second_chunk), + (terminal_task, terminal_event), + ] + ) + + stream = a2a_agent.run("Hello", stream=True) + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + response = await stream.get_final_response() + + assert [update.text for update in updates] == ["Hello ", "world"] + assert response.text == "Hello world" + assert len(response.messages) == 1 + + +async def test_streaming_terminal_task_artifacts_are_emitted_when_terminal_event_has_no_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that terminal task artifacts are still emitted when the final status event has no message.""" + terminal_task = Task( + id="task-art-final", + context_id="ctx-art-final", + status=TaskStatus(state=TaskState.completed, message=None), + artifacts=[ + Artifact( + artifact_id="artifact-final", + parts=[Part(root=TextPart(text="Final artifact"))], + ) + ], + ) + terminal_event = TaskStatusUpdateEvent( + task_id="task-art-final", + context_id="ctx-art-final", + status=TaskStatus(state=TaskState.completed, message=None), + final=True, + ) + mock_a2a_client.responses.append((terminal_task, terminal_event)) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].text == "Final artifact" + assert updates[0].message_id == "artifact-final" + + +async def test_streaming_terminal_task_only_emits_unstreamed_artifacts( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that the terminal task only emits artifacts that were not already streamed incrementally.""" + working_task = Task(id="task-art-mixed", context_id="ctx-art-mixed", status=TaskStatus(state=TaskState.working)) + streamed_chunk = TaskArtifactUpdateEvent( + task_id="task-art-mixed", + context_id="ctx-art-mixed", + artifact=Artifact( + artifact_id="artifact-streamed", + parts=[Part(root=TextPart(text="Hello"))], + ), + append=False, + ) + terminal_task = Task( + id="task-art-mixed", + context_id="ctx-art-mixed", + status=TaskStatus(state=TaskState.completed, message=None), + artifacts=[ + Artifact( + artifact_id="artifact-streamed", + parts=[Part(root=TextPart(text="Hello"))], + ), + Artifact( + artifact_id="artifact-final", + parts=[Part(root=TextPart(text="Goodbye"))], + ), + ], + ) + terminal_event = TaskStatusUpdateEvent( + task_id="task-art-mixed", + context_id="ctx-art-mixed", + status=TaskStatus(state=TaskState.completed, message=None), + final=True, + ) + + mock_a2a_client.responses.extend( + [ + (working_task, streamed_chunk), + (terminal_task, terminal_event), + ] + ) + + stream = a2a_agent.run("Hello", stream=True) + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + response = await stream.get_final_response() + + assert [update.text for update in updates] == ["Hello", "Goodbye"] + assert [message.text for message in response.messages] == ["Hello", "Goodbye"] + + # endregion diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index d37d37bdfa..22841900d6 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -15,16 +15,16 @@ pip install agent-framework-ag-ui ```python from fastapi import FastAPI from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Create your agent agent = Agent( name="my_agent", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient( - endpoint="https://your-resource.openai.azure.com/", - deployment_name="gpt-4o-mini", + client=OpenAIChatCompletionClient( + azure_endpoint="https://your-resource.openai.azure.com/", + model="gpt-4o-mini", api_key="your-api-key", ), ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_types.py b/python/packages/ag-ui/agent_framework_ag_ui/_types.py index 4c5467bd64..c8ee728df3 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_types.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_types.py @@ -108,7 +108,7 @@ class AGUIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], tota Keys: # Inherited from ChatOptions (forwarded to remote server): - model_id: The model identifier (forwarded as-is to server). + model: The model identifier (forwarded as-is to server). temperature: Sampling temperature. top_p: Nucleus sampling parameter. max_tokens: Maximum tokens to generate. diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md index 04332dab9c..d4caa856ca 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md @@ -16,7 +16,7 @@ All example agents are factory functions that accept any `SupportsChatGetRespons ```python from fastapi import FastAPI -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.openai import OpenAIChatClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent @@ -24,11 +24,11 @@ from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent app = FastAPI() # Option 1: Use Azure OpenAI -azure_client = AzureOpenAIChatClient(model_id="gpt-4") +azure_client = OpenAIChatCompletionClient(model="gpt-4") add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat") # Option 2: Use OpenAI -openai_client = OpenAIChatClient(model_id="gpt-4o") +openai_client = OpenAIChatClient(model="gpt-4o") add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather") # Run with: uvicorn main:app --reload @@ -39,14 +39,14 @@ add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weathe ```python from fastapi import FastAPI from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Create your agent agent = Agent( name="my_agent", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatCompletionClient(model="gpt-4o"), ) # Create FastAPI app and add AG-UI endpoint @@ -90,7 +90,7 @@ Complete examples for all AG-UI features are available: ### Using Example Agents ```python -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.openai import OpenAIChatClient from agent_framework_ag_ui_examples.agents import ( simple_agent, @@ -99,8 +99,8 @@ from agent_framework_ag_ui_examples.agents import ( ) # Create a chat client (use any SupportsChatGetResponse implementation) -azure_client = AzureOpenAIChatClient(model_id="gpt-4") -openai_client = OpenAIChatClient(model_id="gpt-4o") +azure_client = OpenAIChatCompletionClient(model="gpt-4") +openai_client = OpenAIChatClient(model="gpt-4o") # Create agent instances by calling the factory functions agent1 = simple_agent(azure_client) @@ -137,7 +137,7 @@ The server exposes endpoints at: ```python from fastapi import FastAPI -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework_ag_ui_examples.agents import ( simple_agent, @@ -153,7 +153,7 @@ from agent_framework_ag_ui_examples.agents import ( app = FastAPI(title="AG-UI Examples") # Create a chat client (shared across all agents, or create individual ones) -client = AzureOpenAIChatClient(model_id="gpt-4") +client = OpenAIChatCompletionClient(model="gpt-4") # Add all example endpoints add_agent_framework_fastapi_endpoint(app, simple_agent(client), "/agentic_chat") @@ -223,8 +223,8 @@ def my_custom_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent: ) # Use it -from agent_framework.azure import AzureOpenAIChatClient -client = AzureOpenAIChatClient() +from agent_framework.openai import OpenAIChatCompletionClient +client = OpenAIChatCompletionClient() agent = my_custom_agent(client) ``` @@ -234,13 +234,13 @@ State is injected as system messages and updated via predictive state updates: ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent agent = Agent( name="recipe_agent", - client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatCompletionClient(model="gpt-4o"), ) state_schema = { @@ -271,13 +271,13 @@ Predictive state updates automatically stream tool arguments as optimistic state ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent agent = Agent( name="document_writer", - client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatCompletionClient(model="gpt-4o"), ) predict_state_config = { diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py index b18fc103e8..7db6ff5278 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py @@ -6,7 +6,7 @@ from typing import Any, cast from agent_framework._clients import SupportsChatGetResponse from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from fastapi import FastAPI from ...agents.weather_agent import weather_agent @@ -19,7 +19,7 @@ def register_backend_tool_rendering(app: FastAPI) -> None: app: The FastAPI application. """ # Create a chat client and call the factory function - client = cast(SupportsChatGetResponse[Any], AzureOpenAIChatClient()) + client = cast(SupportsChatGetResponse[Any], OpenAIChatCompletionClient()) add_agent_framework_fastapi_endpoint( app, diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index b422d70c8e..31a7c47963 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -12,7 +12,7 @@ import uvicorn from agent_framework import ChatOptions from agent_framework._clients import SupportsChatGetResponse from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -80,7 +80,7 @@ client: SupportsChatGetResponse[ChatOptions] = cast( SupportsChatGetResponse[ChatOptions], AnthropicClient() if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic" - else AzureOpenAIChatClient(), + else OpenAIChatCompletionClient(), ) # Agentic Chat - basic chat agent diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md index d3d14694a5..71bd855c3a 100644 --- a/python/packages/ag-ui/getting_started/README.md +++ b/python/packages/ag-ui/getting_started/README.md @@ -185,19 +185,19 @@ Create a file named `server.py`: import os from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from fastapi import FastAPI # Read required configuration endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") -deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") +model = os.environ.get("AZURE_OPENAI_MODEL") api_key = os.environ.get("AZURE_OPENAI_API_KEY") if not endpoint: raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") -if not deployment_name: - raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required") +if not model: + raise ValueError("AZURE_OPENAI_MODEL environment variable is required") if not api_key: raise ValueError("AZURE_OPENAI_API_KEY environment variable is required") @@ -205,9 +205,9 @@ if not api_key: agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient( - endpoint=endpoint, - deployment_name=deployment_name, + client=OpenAIChatCompletionClient( + azure_endpoint=endpoint, + model=model, api_key=api_key, ), ) @@ -230,7 +230,7 @@ if __name__ == "__main__": - **`Agent`**: The agent that will handle incoming requests - **FastAPI Integration**: Uses FastAPI's native async support for streaming responses - **Instructions**: The agent is created with default instructions, which can be overridden by client messages -- **Configuration**: `AzureOpenAIChatClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly +- **Configuration**: `OpenAIChatCompletionClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_MODEL`, `AZURE_OPENAI_API_KEY`) or accept parameters directly **Alternative (simpler)**: Use environment variables only: @@ -239,7 +239,7 @@ if __name__ == "__main__": agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient(), # Reads from environment automatically + client=OpenAIChatCompletionClient(), # Reads from environment automatically ) ``` @@ -249,7 +249,7 @@ Set the required environment variables: ```bash export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_MODEL="gpt-4o-mini" # Optional: Set API key if not using DefaultAzureCredential # export AZURE_OPENAI_API_KEY="your-api-key" ``` diff --git a/python/packages/ag-ui/getting_started/client.py b/python/packages/ag-ui/getting_started/client.py index fc0dfc3884..8b1013bc61 100644 --- a/python/packages/ag-ui/getting_started/client.py +++ b/python/packages/ag-ui/getting_started/client.py @@ -44,7 +44,7 @@ async def main(): metadata = {"thread_id": thread_id} if thread_id else None stream = client.get_response( - [Message(role="user", text=message)], + [Message(role="user", contents=[message])], stream=True, options={"metadata": metadata} if metadata else None, ) diff --git a/python/packages/ag-ui/getting_started/client_advanced.py b/python/packages/ag-ui/getting_started/client_advanced.py index 25f1edc7a4..74bb8e74b8 100644 --- a/python/packages/ag-ui/getting_started/client_advanced.py +++ b/python/packages/ag-ui/getting_started/client_advanced.py @@ -73,7 +73,7 @@ async def streaming_example(client: AGUIChatClient, thread_id: str | None = None print("Assistant: ", end="", flush=True) stream = client.get_response( - [Message(role="user", text="Tell me a short joke")], + [Message(role="user", contents=["Tell me a short joke"])], stream=True, options={"metadata": metadata} if metadata else None, ) @@ -100,7 +100,7 @@ async def non_streaming_example(client: AGUIChatClient, thread_id: str | None = print("\nUser: What is 2 + 2?\n") - response = await client.get_response([Message(role="user", text="What is 2 + 2?")], metadata=metadata) + response = await client.get_response([Message(role="user", contents=["What is 2 + 2?"])], metadata=metadata) print(f"Assistant: {response.text}") @@ -139,7 +139,9 @@ async def tool_example(client: AGUIChatClient, thread_id: str | None = None): print("(Server must be configured with matching tools to execute them)\n") response = await client.get_response( - [Message(role="user", text="What's the weather in Seattle?")], tools=[get_weather, calculate], metadata=metadata + [Message(role="user", contents=["What's the weather in Seattle?"])], + tools=[get_weather, calculate], + metadata=metadata, ) print(f"Assistant: {response.text}") @@ -174,7 +176,7 @@ async def conversation_example(client: AGUIChatClient): # First turn print("User: My name is Alice\n") - response1 = await client.get_response([Message(role="user", text="My name is Alice")]) + response1 = await client.get_response([Message(role="user", contents=["My name is Alice"])]) print(f"Assistant: {response1.text}") thread_id = response1.additional_properties.get("thread_id") print(f"\n[Thread: {thread_id}]") @@ -182,7 +184,7 @@ async def conversation_example(client: AGUIChatClient): # Second turn - using same thread print("\nUser: What's my name?\n") response2 = await client.get_response( - [Message(role="user", text="What's my name?")], options={"metadata": {"thread_id": thread_id}} + [Message(role="user", contents=["What's my name?"])], options={"metadata": {"thread_id": thread_id}} ) print(f"Assistant: {response2.text}") @@ -193,7 +195,7 @@ async def conversation_example(client: AGUIChatClient): # Third turn print("\nUser: Can you also tell me what 10 * 5 is?\n") response3 = await client.get_response( - [Message(role="user", text="Can you also tell me what 10 * 5 is?")], + [Message(role="user", contents=["Can you also tell me what 10 * 5 is?"])], options={"metadata": {"thread_id": thread_id}}, tools=[calculate], ) diff --git a/python/packages/ag-ui/getting_started/server.py b/python/packages/ag-ui/getting_started/server.py index 8d32009fb1..4dfe0b3614 100644 --- a/python/packages/ag-ui/getting_started/server.py +++ b/python/packages/ag-ui/getting_started/server.py @@ -9,7 +9,7 @@ import os from agent_framework import Agent, tool from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException, Security from fastapi.security import APIKeyHeader @@ -26,12 +26,12 @@ logger = logging.getLogger(__name__) # Read required configuration endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") -deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") +model = os.environ.get("AZURE_OPENAI_MODEL") if not endpoint: raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") -if not deployment_name: - raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required") +if not model: + raise ValueError("AZURE_OPENAI_MODEL environment variable is required") # ============================================================================ @@ -119,9 +119,9 @@ def get_time_zone(location: str) -> str: agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.", - client=AzureOpenAIChatClient( - endpoint=endpoint, - deployment_name=deployment_name, + client=OpenAIChatCompletionClient( + azure_endpoint=endpoint, + model=model, ), tools=[get_time_zone], # ONLY server-side tools ) diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index dbe798fc81..36f9bb805f 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260330" +version = "1.0.0b260402" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "ag-ui-protocol==0.1.13", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py index df6359b8ba..d473e5becc 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py +++ b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py @@ -67,8 +67,8 @@ class TestAGUIChatClient: """Test state extraction when no state is present.""" client = StubAGUIChatClient(endpoint="http://localhost:8888/") messages = [ - Message(role="user", text="Hello"), - Message(role="assistant", text="Hi there"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Hi there"]), ] result_messages, state = client.extract_state_from_messages(messages) @@ -87,7 +87,7 @@ class TestAGUIChatClient: state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8") messages = [ - Message(role="user", text="Hello"), + Message(role="user", contents=["Hello"]), Message( role="user", contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")], @@ -125,8 +125,8 @@ class TestAGUIChatClient: """Test message conversion to AG-UI format.""" client = StubAGUIChatClient(endpoint="http://localhost:8888/") messages = [ - Message(role="user", text="What is the weather?"), - Message(role="assistant", text="Let me check.", message_id="msg_123"), + Message(role="user", contents=["What is the weather?"]), + Message(role="assistant", contents=["Let me check."], message_id="msg_123"), ] agui_messages = client.convert_messages_to_agui_format(messages) @@ -173,7 +173,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] chat_options = ChatOptions() updates: list[ChatResponseUpdate] = [] @@ -206,7 +206,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] chat_options = {} response = await client.inner_get_response(messages=messages, options=chat_options) @@ -249,7 +249,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="Test with tools")] + messages = [Message(role="user", contents=["Test with tools"])] chat_options = ChatOptions(tools=[test_tool]) response = await client.inner_get_response(messages=messages, options=chat_options) @@ -273,7 +273,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="Test server tool execution")] + messages = [Message(role="user", contents=["Test server tool execution"])] updates: list[ChatResponseUpdate] = [] async for update in client.get_response(messages, stream=True): @@ -315,7 +315,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="Test server tool execution")] + messages = [Message(role="user", contents=["Test server tool execution"])] async for _ in client.get_response( messages, stream=True, options={"tool_choice": "auto", "tools": [client_tool]} @@ -331,7 +331,7 @@ class TestAGUIChatClient: state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8") messages = [ - Message(role="user", text="Hello"), + Message(role="user", contents=["Hello"]), Message( role="user", contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")], @@ -388,7 +388,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] response = await client.inner_get_response(messages=messages, options={}, stream=False) assert response is not None @@ -416,7 +416,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] updates: list[ChatResponseUpdate] = [] async for update in client._inner_get_response(messages=messages, stream=True, options={"tools": [my_tool]}): updates.append(update) @@ -451,7 +451,7 @@ class TestAGUIChatClient: client = StubAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [Message(role="user", text="continue")] + messages = [Message(role="user", contents=["continue"])] options = { "available_interrupts": available_interrupts, "resume": resume_payload, diff --git a/python/packages/anthropic/AGENTS.md b/python/packages/anthropic/AGENTS.md index 748f9a26f0..ce70e9d9a0 100644 --- a/python/packages/anthropic/AGENTS.md +++ b/python/packages/anthropic/AGENTS.md @@ -5,6 +5,9 @@ Integration with Anthropic's Claude API. ## Main Classes - **`AnthropicClient`** - Chat client for Anthropic Claude models +- **`AnthropicFoundryClient`** - Anthropic chat client for Azure AI Foundry's Anthropic-compatible endpoint +- **`AnthropicBedrockClient`** - Anthropic chat client for Amazon Bedrock +- **`AnthropicVertexClient`** - Anthropic chat client for Google Vertex AI - **`AnthropicChatOptions`** - Options TypedDict for Anthropic-specific parameters ## Usage @@ -12,7 +15,7 @@ Integration with Anthropic's Claude API. ```python from agent_framework.anthropic import AnthropicClient -client = AnthropicClient(model_id="claude-sonnet-4-20250514") +client = AnthropicClient(model="claude-sonnet-4-20250514") response = await client.get_response("Hello") ``` diff --git a/python/packages/anthropic/README.md b/python/packages/anthropic/README.md index 2507837d2c..f27b53acdc 100644 --- a/python/packages/anthropic/README.md +++ b/python/packages/anthropic/README.md @@ -10,6 +10,12 @@ pip install agent-framework-anthropic --pre The Anthropic integration enables communication with the Anthropic API, allowing your Agent Framework applications to leverage Anthropic's capabilities. +The package also includes Anthropic-hosted transport wrappers for: + +- Azure AI Foundry via `AnthropicFoundryClient` +- Amazon Bedrock via `AnthropicBedrockClient` +- Google Vertex AI via `AnthropicVertexClient` + ### Basic Usage Example See the [Anthropic agent examples](../../samples/02-agents/providers/anthropic/) which demonstrate: diff --git a/python/packages/anthropic/agent_framework_anthropic/__init__.py b/python/packages/anthropic/agent_framework_anthropic/__init__.py index ad0cff9648..0f4c1c9148 100644 --- a/python/packages/anthropic/agent_framework_anthropic/__init__.py +++ b/python/packages/anthropic/agent_framework_anthropic/__init__.py @@ -2,7 +2,10 @@ import importlib.metadata +from ._bedrock_client import AnthropicBedrockClient, RawAnthropicBedrockClient from ._chat_client import AnthropicChatOptions, AnthropicClient, RawAnthropicClient +from ._foundry_client import AnthropicFoundryClient, RawAnthropicFoundryClient +from ._vertex_client import AnthropicVertexClient, RawAnthropicVertexClient try: __version__ = importlib.metadata.version(__name__) @@ -10,8 +13,14 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode __all__ = [ + "AnthropicBedrockClient", "AnthropicChatOptions", "AnthropicClient", + "AnthropicFoundryClient", + "AnthropicVertexClient", + "RawAnthropicBedrockClient", "RawAnthropicClient", + "RawAnthropicFoundryClient", + "RawAnthropicVertexClient", "__version__", ] diff --git a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py new file mode 100644 index 0000000000..8b7b6bd71b --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, +) +from agent_framework._settings import SecretString, load_settings +from agent_framework.observability import ChatTelemetryLayer +from anthropic import AsyncAnthropicBedrock + +from ._chat_client import AnthropicOptionsT, RawAnthropicClient + + +class AnthropicBedrockSettings(TypedDict, total=False): + """Resolved settings for Anthropic Bedrock wrappers.""" + + aws_access_key_id: SecretString | None + aws_secret_access_key: SecretString | None + aws_region: str | None + aws_profile: str | None + aws_session_token: SecretString | None + anthropic_bedrock_base_url: str | None + anthropic_chat_model: str | None + + +class RawAnthropicBedrockClient(RawAnthropicClient[AnthropicOptionsT], Generic[AnthropicOptionsT]): + """Raw Anthropic Bedrock chat client without middleware, telemetry, or function invocation support.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "aws.bedrock" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + model: str | None = None, + aws_secret_key: str | None = None, + aws_access_key: str | None = None, + aws_region: str | None = None, + aws_profile: str | None = None, + aws_session_token: str | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicBedrock | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw Anthropic Bedrock client. + + Keyword Args: + model: The Anthropic model to use. + aws_secret_key: AWS secret access key. + aws_access_key: AWS access key ID. + aws_region: AWS region. + aws_profile: AWS profile name. + aws_session_token: AWS session token. + base_url: Optional custom Anthropic Bedrock base URL. + anthropic_client: Existing AsyncAnthropicBedrock client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + settings = load_settings( + AnthropicBedrockSettings, + env_prefix="", + aws_access_key_id=aws_access_key, + aws_secret_access_key=aws_secret_key, + aws_region=aws_region, + aws_profile=aws_profile, + aws_session_token=aws_session_token, + anthropic_bedrock_base_url=base_url, + anthropic_chat_model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + model_setting = settings.get("anthropic_chat_model") + access_key_secret = settings.get("aws_access_key_id") + secret_key_secret = settings.get("aws_secret_access_key") + session_token_secret = settings.get("aws_session_token") + + if anthropic_client is None: + anthropic_client = AsyncAnthropicBedrock( + aws_secret_key=secret_key_secret.get_secret_value() if secret_key_secret is not None else None, + aws_access_key=access_key_secret.get_secret_value() if access_key_secret is not None else None, + aws_region=settings.get("aws_region"), + aws_profile=settings.get("aws_profile"), + aws_session_token=session_token_secret.get_secret_value() if session_token_secret is not None else None, + base_url=settings.get("anthropic_bedrock_base_url"), + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + super().__init__( + model=model_setting, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + ) + + +class AnthropicBedrockClient( # type: ignore[misc] + FunctionInvocationLayer[AnthropicOptionsT], + ChatMiddlewareLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + RawAnthropicBedrockClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], +): + """Anthropic Bedrock chat client with middleware, telemetry, and function invocation support.""" + + def __init__( + self, + *, + model: str | None = None, + aws_secret_key: str | None = None, + aws_access_key: str | None = None, + aws_region: str | None = None, + aws_profile: str | None = None, + aws_session_token: str | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicBedrock | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Anthropic Bedrock client. + + Keyword Args: + model: The Anthropic model to use. + aws_secret_key: AWS secret access key. + aws_access_key: AWS access key ID. + aws_region: AWS region. + aws_profile: AWS profile name. + aws_session_token: AWS session token. + base_url: Optional custom Anthropic Bedrock base URL. + anthropic_client: Existing AsyncAnthropicBedrock client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + super().__init__( + model=model, + aws_secret_key=aws_secret_key, + aws_access_key=aws_access_key, + aws_region=aws_region, + aws_profile=aws_profile, + aws_session_token=aws_session_token, + base_url=base_url, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index b3b61a4640..6e5e5f14a6 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -31,7 +31,7 @@ from agent_framework._settings import SecretString, load_settings from agent_framework._tools import SHELL_TOOL_KIND_VALUE from agent_framework._types import _get_data_bytes_as_str # type: ignore from agent_framework.observability import ChatTelemetryLayer -from anthropic import AsyncAnthropic +from anthropic import AsyncAnthropic, AsyncAnthropicBedrock, AsyncAnthropicFoundry, AsyncAnthropicVertex from anthropic.types.beta import ( BetaContentBlock, BetaMessage, @@ -79,6 +79,7 @@ BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08 STRUCTURED_OUTPUTS_BETA_FLAG: Final[str] = "structured-outputs-2025-11-13" ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex # region Anthropic Chat Options TypedDict @@ -113,8 +114,6 @@ class AnthropicChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], a default of 1024 will be used. Keys: - model_id: The model to use for the request, - translates to ``model`` in Anthropic API. temperature: Sampling temperature between 0 and 1. top_p: Nucleus sampling parameter. max_tokens: Maximum number of tokens to generate (REQUIRED). @@ -169,12 +168,24 @@ AnthropicOptionsT = TypeVar( # Translation between framework options keys and Anthropic Messages API OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", "stop": "stop_sequences", "instructions": "system", } +def _apply_option_translations(options: dict[str, Any]) -> None: + """Translate framework option keys to Anthropic request keys in-place. + + When both the old and new key are present, the new key wins and the old key + is discarded to preserve explicit overrides. + """ + for old_key, new_key in OPTION_TRANSLATIONS.items(): + if old_key not in options or old_key == new_key: + continue + old_value = options.pop(old_key) + options.setdefault(new_key, old_value) + + # region Role and Finish Reason Maps @@ -204,11 +215,11 @@ class AnthropicSettings(TypedDict, total=False): Keys: api_key: The Anthropic API key. - chat_model_id: The Anthropic chat model ID. + chat_model: The Anthropic chat model. """ api_key: SecretString | None - chat_model_id: str | None + chat_model: str | None class RawAnthropicClient( @@ -236,8 +247,8 @@ class RawAnthropicClient( self, *, api_key: str | None = None, - model_id: str | None = None, - anthropic_client: AsyncAnthropic | None = None, + model: str | None = None, + anthropic_client: AnthropicAsyncClient | None = None, additional_beta_flags: list[str] | None = None, additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, @@ -247,7 +258,7 @@ class RawAnthropicClient( Keyword Args: api_key: The Anthropic API key to use for authentication. - model_id: The ID of the model to use. + model: The model to use. anthropic_client: An existing Anthropic client to use. If not provided, one will be created. This can be used to further configure the client before passing it in. For instance if you need to set a different base_url for testing or private deployments. @@ -265,11 +276,11 @@ class RawAnthropicClient( # Using environment variables # Set ANTHROPIC_API_KEY=your_anthropic_api_key - # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + # ANTHROPIC_CHAT_MODEL=claude-sonnet-4-5-20250929 # Or passing parameters directly client = RawAnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", api_key="your_anthropic_api_key", ) @@ -283,7 +294,7 @@ class RawAnthropicClient( api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" ) client = RawAnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", anthropic_client=anthropic_client, ) @@ -296,7 +307,7 @@ class RawAnthropicClient( my_custom_option: str - client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model_id="claude-sonnet-4-5-20250929") + client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model="claude-sonnet-4-5-20250929") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ @@ -304,13 +315,13 @@ class RawAnthropicClient( AnthropicSettings, env_prefix="ANTHROPIC_", api_key=api_key, - chat_model_id=model_id, + chat_model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) api_key_secret = anthropic_settings.get("api_key") - model_id_setting = anthropic_settings.get("chat_model_id") + model_setting = anthropic_settings.get("chat_model") if anthropic_client is None: if api_key_secret is None: @@ -332,7 +343,7 @@ class RawAnthropicClient( # Initialize instance variables self.anthropic_client = anthropic_client self.additional_beta_flags = additional_beta_flags or [] - self.model_id = model_id_setting + self.model = model_setting # streaming requires tracking the last function call ID, name, and content type self._last_call_id_name: tuple[str, str] | None = None self._last_call_content_type: str | None = None @@ -513,7 +524,7 @@ class RawAnthropicClient( if stream: # Streaming mode async def _stream() -> AsyncIterable[ChatResponseUpdate]: - async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): + async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): # type: ignore[misc] parsed_chunk = self._process_stream_event(chunk) if parsed_chunk: yield parsed_chunk @@ -522,7 +533,7 @@ class RawAnthropicClient( # Non-streaming mode async def _get_response() -> ChatResponse: - message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) # type: ignore[misc] return self._process_message(message, options) return _get_response() @@ -561,16 +572,22 @@ class RawAnthropicClient( # Stream mode is controlled explicitly at call sites. run_options.pop("stream", None) - # Translation between options keys and Anthropic Messages API - for old_key, new_key in OPTION_TRANSLATIONS.items(): - if old_key in run_options and old_key != new_key: - run_options[new_key] = run_options.pop(old_key) + _apply_option_translations(run_options) - # model id + # Filter out framework kwargs that should not be passed to the Anthropic API. + # This includes underscore-prefixed internal objects (like _function_middleware_pipeline) + # and framework kwargs like 'thread' and 'middleware'. + filtered_kwargs = { + k: v for k, v in kwargs.items() if not k.startswith("_") and k not in {"thread", "middleware"} + } + _apply_option_translations(filtered_kwargs) + run_options.update(filtered_kwargs) + + # model if not run_options.get("model"): - if not self.model_id: - raise ValueError("model_id must be a non-empty string") - run_options["model"] = self.model_id + if not self.model: + raise ValueError("model must be a non-empty string") + run_options["model"] = self.model # max_tokens - Anthropic requires this, default if not provided if not run_options.get("max_tokens"): @@ -607,13 +624,6 @@ class RawAnthropicClient( # Add the structured outputs beta flag run_options["betas"].add(STRUCTURED_OUTPUTS_BETA_FLAG) - # Filter out framework kwargs that should not be passed to the Anthropic API. - # This includes underscore-prefixed internal objects (like _function_middleware_pipeline) - # and framework kwargs like 'thread' and 'middleware'. - filtered_kwargs = { - k: v for k, v in kwargs.items() if not k.startswith("_") and k not in {"thread", "middleware"} - } - run_options.update(filtered_kwargs) return run_options def _prepare_betas(self, options: Mapping[str, Any]) -> set[str]: @@ -918,7 +928,7 @@ class RawAnthropicClient( ) ], usage_details=self._parse_usage_from_anthropic(message.usage), - model_id=message.model, + model=message.model, finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None, response_format=options.get("response_format"), raw_representation=message, @@ -946,7 +956,7 @@ class RawAnthropicClient( *self._parse_contents_from_anthropic(event.message.content), *usage_details, ], - model_id=event.message.model, + model=event.message.model, finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason) if event.message.stop_reason else None, @@ -1271,8 +1281,8 @@ class RawAnthropicClient( ) ) case "input_json_delta": - # Skip argument deltas for MCP tools — execution is handled server-side. - if self._last_call_content_type == "mcp_tool_use": + # Skip argument deltas for MCP and server tools — execution is handled server-side. + if self._last_call_content_type in ("mcp_tool_use", "server_tool_use"): pass else: call_id = self._last_call_id_name[0] if self._last_call_id_name else "" @@ -1396,8 +1406,8 @@ class AnthropicClient( self, *, api_key: str | None = None, - model_id: str | None = None, - anthropic_client: AsyncAnthropic | None = None, + model: str | None = None, + anthropic_client: AnthropicAsyncClient | None = None, additional_beta_flags: list[str] | None = None, additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -1409,7 +1419,7 @@ class AnthropicClient( Keyword Args: api_key: The Anthropic API key to use for authentication. - model_id: The ID of the model to use. + model: The model to use. anthropic_client: An existing Anthropic client to use. If not provided, one will be created. This can be used to further configure the client before passing it in. For instance if you need to set a different base_url for testing or private deployments. @@ -1428,11 +1438,11 @@ class AnthropicClient( # Using environment variables # Set ANTHROPIC_API_KEY=your_anthropic_api_key - # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + # ANTHROPIC_CHAT_MODEL=claude-sonnet-4-5-20250929 # Or passing parameters directly client = AnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", api_key="your_anthropic_api_key", ) @@ -1446,7 +1456,7 @@ class AnthropicClient( api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" ) client = AnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", anthropic_client=anthropic_client, ) @@ -1459,12 +1469,12 @@ class AnthropicClient( my_custom_option: str - client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client: AnthropicClient[MyOptions] = AnthropicClient(model="claude-sonnet-4-5-20250929") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ super().__init__( api_key=api_key, - model_id=model_id, + model=model, anthropic_client=anthropic_client, additional_beta_flags=additional_beta_flags, additional_properties=additional_properties, diff --git a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py new file mode 100644 index 0000000000..f5fe37296f --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, +) +from agent_framework._settings import SecretString, load_settings +from agent_framework.observability import ChatTelemetryLayer +from anthropic import AsyncAnthropicFoundry + +from ._chat_client import AnthropicOptionsT, RawAnthropicClient + +AnthropicFoundryAzureADTokenProvider = Callable[[], str | Awaitable[str]] + + +class AnthropicFoundrySettings(TypedDict, total=False): + """Resolved settings for Anthropic Foundry wrappers.""" + + anthropic_foundry_api_key: SecretString | None + anthropic_foundry_resource: str | None + anthropic_foundry_base_url: str | None + anthropic_chat_model: str | None + + +class RawAnthropicFoundryClient(RawAnthropicClient[AnthropicOptionsT], Generic[AnthropicOptionsT]): + """Raw Anthropic Foundry chat client without middleware, telemetry, or function invocation support.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + model: str | None = None, + resource: str | None = None, + api_key: str | None = None, + azure_ad_token_provider: AnthropicFoundryAzureADTokenProvider | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicFoundry | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw Anthropic Foundry client. + + Keyword Args: + model: The Anthropic model to use. + resource: The Foundry resource name. + api_key: The Foundry Anthropic API key. + azure_ad_token_provider: Azure AD token provider used by the Anthropic SDK. + base_url: Full Anthropic-compatible Foundry base URL. + anthropic_client: Existing AsyncAnthropicFoundry client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + settings = load_settings( + AnthropicFoundrySettings, + env_prefix="", + anthropic_foundry_api_key=api_key, + anthropic_foundry_resource=resource, + anthropic_foundry_base_url=base_url, + anthropic_chat_model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + api_key_secret = settings.get("anthropic_foundry_api_key") + model_setting = settings.get("anthropic_chat_model") + resource_setting = settings.get("anthropic_foundry_resource") + base_url_setting = settings.get("anthropic_foundry_base_url") + api_key_value = api_key_secret.get_secret_value() if api_key_secret is not None else None + + if anthropic_client is None: + if base_url_setting is None and resource_setting is None: + message = ( + "Anthropic Foundry requires either `resource`/`ANTHROPIC_FOUNDRY_RESOURCE` " + "or `base_url`/`ANTHROPIC_FOUNDRY_BASE_URL`." + ) + raise ValueError(message) + if base_url_setting is not None: + anthropic_client = AsyncAnthropicFoundry( + base_url=base_url_setting, + api_key=api_key_value, + azure_ad_token_provider=azure_ad_token_provider, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + else: + anthropic_client = AsyncAnthropicFoundry( + resource=resource_setting, + api_key=api_key_value, + azure_ad_token_provider=azure_ad_token_provider, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + super().__init__( + model=model_setting, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + ) + + +class AnthropicFoundryClient( # type: ignore[misc] + FunctionInvocationLayer[AnthropicOptionsT], + ChatMiddlewareLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + RawAnthropicFoundryClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], +): + """Anthropic Foundry chat client with middleware, telemetry, and function invocation support.""" + + def __init__( + self, + *, + model: str | None = None, + resource: str | None = None, + api_key: str | None = None, + azure_ad_token_provider: AnthropicFoundryAzureADTokenProvider | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicFoundry | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Anthropic Foundry client. + + Keyword Args: + model: The Anthropic model to use. + resource: The Foundry resource name. + api_key: The Foundry Anthropic API key. + azure_ad_token_provider: Azure AD token provider used by the Anthropic SDK. + base_url: Full Anthropic-compatible Foundry base URL. + anthropic_client: Existing AsyncAnthropicFoundry client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + super().__init__( + model=model, + resource=resource, + api_key=api_key, + azure_ad_token_provider=azure_ad_token_provider, + base_url=base_url, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py new file mode 100644 index 0000000000..73b16af4a7 --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py @@ -0,0 +1,160 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, +) +from agent_framework._settings import load_settings +from agent_framework.observability import ChatTelemetryLayer +from anthropic import NOT_GIVEN, AsyncAnthropicVertex + +from ._chat_client import AnthropicOptionsT, RawAnthropicClient + +if TYPE_CHECKING: + from google.auth.credentials import Credentials as GoogleCredentials + + +class AnthropicVertexSettings(TypedDict, total=False): + """Resolved settings for Anthropic Vertex wrappers.""" + + cloud_ml_region: str | None + anthropic_vertex_project_id: str | None + anthropic_vertex_base_url: str | None + anthropic_chat_model: str | None + + +class RawAnthropicVertexClient(RawAnthropicClient[AnthropicOptionsT], Generic[AnthropicOptionsT]): + """Raw Anthropic Vertex chat client without middleware, telemetry, or function invocation support.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "google.vertex.ai" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + model: str | None = None, + region: str | None = None, + project_id: str | None = None, + access_token: str | None = None, + credentials: GoogleCredentials | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicVertex | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw Anthropic Vertex client. + + Keyword Args: + model: The Anthropic model to use. + region: Vertex region. Falls back to `CLOUD_ML_REGION`. + project_id: Vertex project ID. Falls back to `ANTHROPIC_VERTEX_PROJECT_ID`. + access_token: Explicit OAuth access token. + credentials: Google credentials object. + base_url: Optional custom Anthropic Vertex base URL. + anthropic_client: Existing AsyncAnthropicVertex client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + settings = load_settings( + AnthropicVertexSettings, + env_prefix="", + cloud_ml_region=region, + anthropic_vertex_project_id=project_id, + anthropic_vertex_base_url=base_url, + anthropic_chat_model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + model_setting = settings.get("anthropic_chat_model") + region_setting = settings.get("cloud_ml_region") + project_id_setting = settings.get("anthropic_vertex_project_id") + + if anthropic_client is None: + resolved_region = region_setting if region_setting is not None else NOT_GIVEN + resolved_project_id = project_id_setting if project_id_setting is not None else NOT_GIVEN + anthropic_client = AsyncAnthropicVertex( + region=resolved_region, + project_id=resolved_project_id, + access_token=access_token, + credentials=credentials, + base_url=settings.get("anthropic_vertex_base_url"), + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + super().__init__( + model=model_setting, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + ) + + +class AnthropicVertexClient( # type: ignore[misc] + FunctionInvocationLayer[AnthropicOptionsT], + ChatMiddlewareLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + RawAnthropicVertexClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], +): + """Anthropic Vertex chat client with middleware, telemetry, and function invocation support.""" + + def __init__( + self, + *, + model: str | None = None, + region: str | None = None, + project_id: str | None = None, + access_token: str | None = None, + credentials: GoogleCredentials | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicVertex | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Anthropic Vertex client. + + Keyword Args: + model: The Anthropic model to use. + region: Vertex region. Falls back to `CLOUD_ML_REGION`. + project_id: Vertex project ID. Falls back to `ANTHROPIC_VERTEX_PROJECT_ID`. + access_token: Explicit OAuth access token. + credentials: Google credentials object. + base_url: Optional custom Anthropic Vertex base URL. + anthropic_client: Existing AsyncAnthropicVertex client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + super().__init__( + model=model, + region=region, + project_id=project_id, + access_token=access_token, + credentials=credentials, + base_url=base_url, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index ff89124577..feed99a503 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/anthropic/tests/conftest.py b/python/packages/anthropic/tests/conftest.py index a2313f4d39..bbe92abea8 100644 --- a/python/packages/anthropic/tests/conftest.py +++ b/python/packages/anthropic/tests/conftest.py @@ -28,7 +28,7 @@ def anthropic_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): env_vars = { "ANTHROPIC_API_KEY": "test-api-key-12345", - "ANTHROPIC_CHAT_MODEL_ID": "claude-3-5-sonnet-20241022", + "ANTHROPIC_CHAT_MODEL": "claude-3-5-sonnet-20241022", } env_vars.update(override_env_param_dict) # type: ignore diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 258cc275ca..52bb4c3a49 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -40,7 +40,7 @@ skip_if_anthropic_integration_tests_disabled = pytest.mark.skipif( def create_test_anthropic_client( mock_anthropic_client: MagicMock, - model_id: str | None = None, + model: str | None = None, anthropic_settings: AnthropicSettings | None = None, ) -> AnthropicClient: """Helper function to create AnthropicClient instances for testing, bypassing normal validation.""" @@ -51,7 +51,7 @@ def create_test_anthropic_client( AnthropicSettings, env_prefix="ANTHROPIC_", api_key="test-api-key-12345", - chat_model_id="claude-3-5-sonnet-20241022", + chat_model="claude-3-5-sonnet-20241022", ) # Create client instance directly @@ -59,7 +59,7 @@ def create_test_anthropic_client( # Set attributes directly client.anthropic_client = mock_anthropic_client - client.model_id = model_id or anthropic_settings["chat_model_id"] + client.model = model or anthropic_settings["chat_model"] client._last_call_id_name = None client._tool_name_aliases = {} client.additional_properties = {} @@ -83,7 +83,7 @@ def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> Non assert settings["api_key"] is not None assert settings["api_key"].get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] - assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + assert settings["chat_model"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] def test_anthropic_settings_init_with_explicit_values() -> None: @@ -92,12 +92,12 @@ def test_anthropic_settings_init_with_explicit_values() -> None: AnthropicSettings, env_prefix="ANTHROPIC_", api_key="custom-api-key", - chat_model_id="claude-3-opus-20240229", + chat_model="claude-3-opus-20240229", ) assert settings["api_key"] is not None assert settings["api_key"].get_secret_value() == "custom-api-key" - assert settings["chat_model_id"] == "claude-3-opus-20240229" + assert settings["chat_model"] == "claude-3-opus-20240229" @pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True) @@ -107,7 +107,7 @@ def test_anthropic_settings_missing_api_key( """Test AnthropicSettings when API key is missing.""" settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_") assert settings["api_key"] is None - assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + assert settings["chat_model"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] # Client Initialization Tests @@ -115,10 +115,10 @@ def test_anthropic_settings_missing_api_key( def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) -> None: """Test AnthropicClient initialization with existing anthropic_client.""" - client = create_test_anthropic_client(mock_anthropic_client, model_id="claude-3-5-sonnet-20241022") + client = create_test_anthropic_client(mock_anthropic_client, model="claude-3-5-sonnet-20241022") assert client.anthropic_client is mock_anthropic_client - assert client.model_id == "claude-3-5-sonnet-20241022" + assert client.model == "claude-3-5-sonnet-20241022" assert isinstance(client, SupportsChatGetResponse) @@ -141,11 +141,11 @@ def test_anthropic_client_init_auto_create_client( """Test AnthropicClient initialization with auto-created anthropic_client.""" client = AnthropicClient( api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"], - model_id=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"], + model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"], ) assert client.anthropic_client is not None - assert client.model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + assert client.model == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] def test_anthropic_client_init_missing_api_key() -> None: @@ -153,7 +153,7 @@ def test_anthropic_client_init_missing_api_key() -> None: with patch("agent_framework_anthropic._chat_client.load_settings") as mock_load: mock_load.return_value = { "api_key": None, - "chat_model_id": "claude-3-5-sonnet-20241022", + "chat_model": "claude-3-5-sonnet-20241022", } with pytest.raises(ValueError, match="Anthropic API key is required"): @@ -172,7 +172,7 @@ def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None: def test_prepare_message_for_anthropic_text(mock_anthropic_client: MagicMock) -> None: """Test converting text message to Anthropic format.""" client = create_test_anthropic_client(mock_anthropic_client) - message = Message(role="user", text="Hello, world!") + message = Message(role="user", contents=["Hello, world!"]) result = client._prepare_message_for_anthropic(message) @@ -491,8 +491,8 @@ def test_prepare_messages_for_anthropic_with_system( """Test converting messages list with system message.""" client = create_test_anthropic_client(mock_anthropic_client) messages = [ - Message(role="system", text="You are a helpful assistant."), - Message(role="user", text="Hello!"), + Message(role="system", contents=["You are a helpful assistant."]), + Message(role="user", contents=["Hello!"]), ] result = client._prepare_messages_for_anthropic(messages) @@ -509,8 +509,8 @@ def test_prepare_messages_for_anthropic_without_system( """Test converting messages list without system message.""" client = create_test_anthropic_client(mock_anthropic_client) messages = [ - Message(role="user", text="Hello!"), - Message(role="assistant", text="Hi there!"), + Message(role="user", contents=["Hello!"]), + Message(role="assistant", contents=["Hi there!"]), ] result = client._prepare_messages_for_anthropic(messages) @@ -735,12 +735,12 @@ async def test_prepare_options_basic(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with basic ChatOptions.""" client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options = ChatOptions(max_tokens=100, temperature=0.7) run_options = client._prepare_options(messages, chat_options) - assert run_options["model"] == client.model_id + assert run_options["model"] == client.model assert run_options["max_tokens"] == 100 assert run_options["temperature"] == 0.7 assert "messages" in run_options @@ -753,8 +753,8 @@ async def test_prepare_options_with_system_message( client = create_test_anthropic_client(mock_anthropic_client) messages = [ - Message(role="system", text="You are helpful."), - Message(role="user", text="Hello"), + Message(role="system", contents=["You are helpful."]), + Message(role="user", contents=["Hello"]), ] chat_options = ChatOptions() @@ -807,7 +807,7 @@ async def test_anthropic_shell_tool_is_invoked_in_function_loop( ] await client.get_response( - messages=[Message(role="user", text="Run pwd")], + messages=[Message(role="user", contents=["Run pwd"])], options={"tools": [shell_tool_instance], "max_tokens": 64}, ) @@ -833,7 +833,7 @@ async def test_prepare_options_with_tool_choice_auto( """Test _prepare_options with auto tool choice.""" client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options = ChatOptions(tool_choice="auto", allow_multiple_tool_calls=False) run_options = client._prepare_options(messages, chat_options) @@ -849,7 +849,7 @@ async def test_prepare_options_with_tool_choice_required( """Test _prepare_options with required tool choice.""" client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] # For required with specific function, need to pass as dict chat_options = ChatOptions(tool_choice={"mode": "required", "required_function_name": "get_weather"}) @@ -865,7 +865,7 @@ async def test_prepare_options_with_tool_choice_none( """Test _prepare_options with none tool choice.""" client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options = ChatOptions(tool_choice="none") run_options = client._prepare_options(messages, chat_options) @@ -882,7 +882,7 @@ async def test_prepare_options_with_tools(mock_anthropic_client: MagicMock) -> N """Get weather for a location.""" return f"Weather for {location}" - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options = ChatOptions(tools=[get_weather]) run_options = client._prepare_options(messages, chat_options) @@ -897,7 +897,7 @@ async def test_prepare_options_with_stop_sequences( """Test _prepare_options with stop sequences.""" client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options = ChatOptions(stop=["STOP", "END"]) run_options = client._prepare_options(messages, chat_options) @@ -909,7 +909,7 @@ async def test_prepare_options_with_top_p(mock_anthropic_client: MagicMock) -> N """Test _prepare_options with top_p.""" client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options = ChatOptions(top_p=0.9) run_options = client._prepare_options(messages, chat_options) @@ -923,7 +923,7 @@ async def test_prepare_options_excludes_stream_option( """Test _prepare_options excludes stream when stream is provided in options.""" client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options: dict[str, Any] = {"stream": True, "max_tokens": 100} run_options = client._prepare_options(messages, chat_options) @@ -941,7 +941,7 @@ async def test_prepare_options_filters_internal_kwargs( """ client = create_test_anthropic_client(mock_anthropic_client) - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] chat_options: ChatOptions = {} # Simulate internal kwargs that get passed through the middleware pipeline @@ -980,7 +980,7 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: response = client._process_message(mock_message, {}) assert response.response_id == "msg_123" - assert response.model_id == "claude-3-5-sonnet-20241022" + assert response.model == "claude-3-5-sonnet-20241022" assert len(response.messages) == 1 assert response.messages[0].role == "assistant" assert len(response.messages[0].contents) == 1 @@ -992,6 +992,27 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: assert response.usage_details["output_token_count"] == 5 +def test_process_message_with_dict_response_format(mock_anthropic_client: MagicMock) -> None: + """_process_message should preserve dict response_format values for response.value parsing.""" + client = create_test_anthropic_client(mock_anthropic_client) + + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_123" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [BetaTextBlock(type="text", text='{"greeting": "Hello"}')] + mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) + mock_message.stop_reason = "end_turn" + + response = client._process_message( + mock_message, + options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}}, + ) + + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["greeting"] == "Hello" + + def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None: """Test _process_message with tool use.""" client = create_test_anthropic_client(mock_anthropic_client) @@ -1123,6 +1144,49 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name( assert result[0].arguments == '"San Francisco"}' +def test_parse_contents_server_tool_use_input_json_delta_ignored( + mock_anthropic_client: MagicMock, +) -> None: + """Regression test: input_json_delta events are ignored after a server_tool_use block. + + Server-managed tools have their execution handled server-side, so streaming + input_json_delta events must not produce Content.from_function_call(name='') + entries that would cause Anthropic API 400 errors on subsequent turns. + """ + client = create_test_anthropic_client(mock_anthropic_client) + + # Simulate a server_tool_use event that sets _last_call_content_type + server_tool_content = MagicMock() + server_tool_content.type = "server_tool_use" + server_tool_content.id = "srvtool_abc" + server_tool_content.name = "web_search" + server_tool_content.input = {} + + result = client._parse_contents_from_anthropic([server_tool_content]) + # server_tool_use falls through to function_call (not mcp_tool_use / code_execution) + assert len(result) == 1 + assert result[0].type == "function_call" + assert client._last_call_content_type == "server_tool_use" # type: ignore[attr-defined] + + # input_json_delta events after server_tool_use must be silently ignored + delta_content = MagicMock() + delta_content.type = "input_json_delta" + delta_content.partial_json = '{"query": "latest news"}' + + result = client._parse_contents_from_anthropic([delta_content]) + assert result == [], "input_json_delta after server_tool_use should produce no content, but got: %r" % result + + # A second delta must also be ignored + delta_content_2 = MagicMock() + delta_content_2.type = "input_json_delta" + delta_content_2.partial_json = '{"extra": true}' + + result = client._parse_contents_from_anthropic([delta_content_2]) + assert result == [], ( + "subsequent input_json_delta after server_tool_use should also be ignored, but got: %r" % result + ) + + # Stream Processing Tests @@ -1154,7 +1218,7 @@ async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None: mock_anthropic_client.beta.messages.create.return_value = mock_message - messages = [Message(role="user", text="Hi")] + messages = [Message(role="user", contents=["Hi"])] chat_options = ChatOptions(max_tokens=10) response = await client._inner_get_response( # type: ignore[attr-defined] @@ -1180,7 +1244,7 @@ async def test_inner_get_response_ignores_options_stream_non_streaming( mock_message.stop_reason = "end_turn" mock_anthropic_client.beta.messages.create.return_value = mock_message - messages = [Message(role="user", text="Hi")] + messages = [Message(role="user", contents=["Hi"])] options: dict[str, Any] = {"max_tokens": 10, "stream": True} await client._inner_get_response( # type: ignore[attr-defined] @@ -1204,7 +1268,7 @@ async def test_inner_get_response_streaming(mock_anthropic_client: MagicMock) -> mock_anthropic_client.beta.messages.create.return_value = mock_stream() - messages = [Message(role="user", text="Hi")] + messages = [Message(role="user", contents=["Hi"])] chat_options = ChatOptions(max_tokens=10) chunks: list[ChatResponseUpdate] = [] @@ -1231,7 +1295,7 @@ async def test_inner_get_response_ignores_options_stream_streaming( mock_anthropic_client.beta.messages.create.return_value = mock_stream() - messages = [Message(role="user", text="Hi")] + messages = [Message(role="user", contents=["Hi"])] options: dict[str, Any] = {"max_tokens": 10, "stream": False} async for _ in client._inner_get_response( # type: ignore[attr-defined] @@ -1385,7 +1449,7 @@ async def test_anthropic_client_integration_basic_chat() -> None: """Integration test for basic chat completion.""" client = AnthropicClient() - messages = [Message(role="user", text="Say 'Hello, World!' and nothing else.")] + messages = [Message(role="user", contents=["Say 'Hello, World!' and nothing else."])] response = await client.get_response(messages=messages, options={"max_tokens": 50}) @@ -1403,7 +1467,7 @@ async def test_anthropic_client_integration_streaming_chat() -> None: """Integration test for streaming chat completion.""" client = AnthropicClient() - messages = [Message(role="user", text="Count from 1 to 5.")] + messages = [Message(role="user", contents=["Count from 1 to 5."])] chunks = [] async for chunk in client.get_response(messages=messages, stream=True, options={"max_tokens": 50}): @@ -1420,7 +1484,7 @@ async def test_anthropic_client_integration_function_calling() -> None: """Integration test for function calling.""" client = AnthropicClient() - messages = [Message(role="user", text="What's the weather in San Francisco?")] + messages = [Message(role="user", contents=["What's the weather in San Francisco?"])] tools = [get_weather] response = await client.get_response( @@ -1441,7 +1505,7 @@ async def test_anthropic_client_integration_hosted_tools() -> None: """Integration test for hosted tools.""" client = AnthropicClient() - messages = [Message(role="user", text="What tools do you have available?")] + messages = [Message(role="user", contents=["What tools do you have available?"])] tools = [ AnthropicClient.get_web_search_tool(), AnthropicClient.get_code_interpreter_tool(), @@ -1468,8 +1532,8 @@ async def test_anthropic_client_integration_with_system_message() -> None: client = AnthropicClient() messages = [ - Message(role="system", text="You are a pirate. Always respond like a pirate."), - Message(role="user", text="Hello!"), + Message(role="system", contents=["You are a pirate. Always respond like a pirate."]), + Message(role="user", contents=["Hello!"]), ] response = await client.get_response(messages=messages, options={"max_tokens": 50}) @@ -1485,7 +1549,7 @@ async def test_anthropic_client_integration_temperature_control() -> None: """Integration test with temperature control.""" client = AnthropicClient() - messages = [Message(role="user", text="Say hello.")] + messages = [Message(role="user", contents=["Say hello."])] response = await client.get_response( messages=messages, @@ -1504,11 +1568,11 @@ async def test_anthropic_client_integration_ordering() -> None: client = AnthropicClient() messages = [ - Message(role="user", text="Say hello."), - Message(role="user", text="Then say goodbye."), - Message(role="assistant", text="Thank you for chatting!"), - Message(role="assistant", text="Let me know if I can help."), - Message(role="user", text="Just testing things."), + Message(role="user", contents=["Say hello."]), + Message(role="user", contents=["Then say goodbye."]), + Message(role="assistant", contents=["Thank you for chatting!"]), + Message(role="assistant", contents=["Let me know if I can help."]), + Message(role="user", contents=["Just testing things."]), ] response = await client.get_response(messages=messages) @@ -2036,10 +2100,10 @@ def test_prepare_options_with_instructions(mock_anthropic_client: MagicMock) -> assert result["max_tokens"] == 1024 -def test_prepare_options_missing_model_id(mock_anthropic_client: MagicMock) -> None: - """Test prepare_options raises error when model_id is missing.""" +def test_prepare_options_missing_model(mock_anthropic_client: MagicMock) -> None: + """Test prepare_options raises error when model is missing.""" client = create_test_anthropic_client(mock_anthropic_client) - client.model_id = "" # Set empty model_id + client.model = "" # Set empty model messages = [Message(role="user", contents=[Content.from_text("Hello")])] options = {} @@ -2048,7 +2112,31 @@ def test_prepare_options_missing_model_id(mock_anthropic_client: MagicMock) -> N client._prepare_options(messages, options) raise AssertionError("Expected ValueError") except ValueError as e: - assert "model_id must be a non-empty string" in str(e) + assert "model must be a non-empty string" in str(e) + + +def test_prepare_options_translates_model_option(mock_anthropic_client: MagicMock) -> None: + """Test prepare_options translates model to model for runtime option compatibility.""" + client = create_test_anthropic_client(mock_anthropic_client) + + messages = [Message(role="user", contents=[Content.from_text("Hello")])] + + result = client._prepare_options(messages, {"model": "claude-3-5-sonnet-20241022"}) + + assert result["model"] == "claude-3-5-sonnet-20241022" + assert "model_id" not in result + + +def test_prepare_options_translates_model_kwarg(mock_anthropic_client: MagicMock) -> None: + """Test prepare_options translates model passed as a direct keyword argument.""" + client = create_test_anthropic_client(mock_anthropic_client) + + messages = [Message(role="user", contents=[Content.from_text("Hello")])] + + result = client._prepare_options(messages, {}, model="claude-3-5-sonnet-20241022") + + assert result["model"] == "claude-3-5-sonnet-20241022" + assert "model_id" not in result def test_prepare_options_with_user_metadata(mock_anthropic_client: MagicMock) -> None: @@ -2593,7 +2681,7 @@ async def test_anthropic_client_integration_tool_rich_content_image() -> None: client = AnthropicClient() client.function_invocation_configuration["max_iterations"] = 2 - messages = [Message(role="user", text="Call the get_test_image tool and describe what you see.")] + messages = [Message(role="user", contents=["Call the get_test_image tool and describe what you see."])] response = await client.get_response( messages=messages, diff --git a/python/packages/anthropic/tests/test_anthropic_provider_clients.py b/python/packages/anthropic/tests/test_anthropic_provider_clients.py new file mode 100644 index 0000000000..d076062fe6 --- /dev/null +++ b/python/packages/anthropic/tests/test_anthropic_provider_clients.py @@ -0,0 +1,156 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer +from agent_framework.observability import ChatTelemetryLayer + +from agent_framework_anthropic import ( + AnthropicBedrockClient, + AnthropicFoundryClient, + AnthropicVertexClient, + RawAnthropicBedrockClient, + RawAnthropicFoundryClient, + RawAnthropicVertexClient, +) + + +def _create_mock_transport(base_url: str) -> MagicMock: + transport = MagicMock() + transport.base_url = base_url + transport.beta = MagicMock() + transport.beta.messages = MagicMock() + transport.beta.messages.create = AsyncMock() + return transport + + +@pytest.mark.parametrize( + ("public_client", "raw_client"), + [ + (AnthropicFoundryClient, RawAnthropicFoundryClient), + (AnthropicBedrockClient, RawAnthropicBedrockClient), + (AnthropicVertexClient, RawAnthropicVertexClient), + ], +) +def test_provider_client_wraps_raw_client_with_standard_layer_order(public_client, raw_client) -> None: + assert issubclass(public_client, raw_client) + mro = public_client.__mro__ + assert mro.index(FunctionInvocationLayer) < mro.index(ChatMiddlewareLayer) + assert mro.index(ChatMiddlewareLayer) < mro.index(ChatTelemetryLayer) + assert mro.index(ChatTelemetryLayer) < mro.index(raw_client) + + +def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_CHAT_MODEL=claude-foundry-test\n" + "ANTHROPIC_FOUNDRY_API_KEY=test-key\n" + "ANTHROPIC_FOUNDRY_RESOURCE=test-resource\n" + ) + mock_transport = _create_mock_transport("https://test-resource.services.ai.azure.com/anthropic/") + + with patch( + "agent_framework_anthropic._foundry_client.AsyncAnthropicFoundry", return_value=mock_transport + ) as factory: + client = RawAnthropicFoundryClient(env_file_path=str(env_file)) + + assert client.model == "claude-foundry-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + resource="test-resource", + api_key="test-key", + azure_ad_token_provider=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + +def test_raw_anthropic_foundry_client_creates_sdk_client_from_base_url_settings(tmp_path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_CHAT_MODEL=claude-foundry-test\n" + "ANTHROPIC_FOUNDRY_API_KEY=test-key\n" + "ANTHROPIC_FOUNDRY_BASE_URL=https://test-resource.services.ai.azure.com/anthropic/\n" + ) + mock_transport = _create_mock_transport("https://test-resource.services.ai.azure.com/anthropic/") + + with patch( + "agent_framework_anthropic._foundry_client.AsyncAnthropicFoundry", return_value=mock_transport + ) as factory: + client = RawAnthropicFoundryClient(env_file_path=str(env_file)) + + assert client.model == "claude-foundry-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + base_url="https://test-resource.services.ai.azure.com/anthropic/", + api_key="test-key", + azure_ad_token_provider=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + +def test_raw_anthropic_foundry_client_requires_resource_or_base_url() -> None: + with patch("agent_framework_anthropic._foundry_client.load_settings") as mock_load: + mock_load.return_value = { + "anthropic_foundry_api_key": None, + "anthropic_foundry_resource": None, + "anthropic_foundry_base_url": None, + "anthropic_chat_model": None, + } + + with pytest.raises( + ValueError, + match=( + "Anthropic Foundry requires either `resource`/`ANTHROPIC_FOUNDRY_RESOURCE` " + "or `base_url`/`ANTHROPIC_FOUNDRY_BASE_URL`\\." + ), + ): + RawAnthropicFoundryClient() + + +def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> None: + mock_transport = _create_mock_transport("https://bedrock-runtime.us-east-1.amazonaws.com") + + with patch( + "agent_framework_anthropic._bedrock_client.AsyncAnthropicBedrock", return_value=mock_transport + ) as factory: + client = RawAnthropicBedrockClient( + model="claude-bedrock-test", + aws_access_key="access-key", + aws_secret_key="secret-key", + aws_region="us-east-1", + ) + + assert client.model == "claude-bedrock-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + aws_secret_key="secret-key", + aws_access_key="access-key", + aws_region="us-east-1", + aws_profile=None, + aws_session_token=None, + base_url=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + +def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None: + mock_transport = _create_mock_transport("https://us-central1-aiplatform.googleapis.com/v1") + + with patch("agent_framework_anthropic._vertex_client.AsyncAnthropicVertex", return_value=mock_transport) as factory: + client = RawAnthropicVertexClient( + model="claude-vertex-test", + region="us-central1", + project_id="test-project", + ) + + assert client.model == "claude-vertex-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + region="us-central1", + project_id="test-project", + access_token=None, + credentials=None, + base_url=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index 0338b13416..de3c190e66 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Azure AI Search context provider using BaseContextProvider. +"""New-pattern Azure AI Search context provider using ContextProvider. This module provides ``AzureAISearchContextProvider``, built on the new -:class:`BaseContextProvider` hooks pattern. +:class:`ContextProvider` hooks pattern. """ from __future__ import annotations @@ -11,11 +11,21 @@ from __future__ import annotations import logging import sys from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Annotation, Content, Message, SupportsGetEmbeddings -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext -from agent_framework._settings import SecretString, load_settings +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentSession, + Annotation, + Content, + ContextProvider, + Message, + SecretString, + SessionContext, + SupportsGetEmbeddings, + load_settings, +) +from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials import AzureKeyCredential, TokenCredential from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ResourceNotFoundError @@ -111,6 +121,9 @@ except ImportError: _agentic_retrieval_available = False AzureCredentialTypes = TokenCredential | AsyncTokenCredential +EmbeddingFunction = Callable[[str], Awaitable[list[float]]] | SupportsGetEmbeddings[str, list[float], Any] +KnowledgeBaseOutputModeLiteral = Literal["extractive_data", "answer_synthesis"] +RetrievalReasoningEffortLiteral = Literal["minimal", "medium", "low"] logger = logging.getLogger("agent_framework.azure_ai_search") @@ -141,8 +154,8 @@ class AzureAISearchSettings(TypedDict, total=False): api_key: SecretString | None -class AzureAISearchContextProvider(BaseContextProvider): - """Azure AI Search context provider using the new BaseContextProvider hooks pattern. +class AzureAISearchContextProvider(ContextProvider): + """Azure AI Search context provider using the new ContextProvider hooks pattern. Retrieves relevant context from Azure AI Search using semantic or agentic search modes. @@ -151,6 +164,222 @@ class AzureAISearchContextProvider(BaseContextProvider): _DEFAULT_SEARCH_CONTEXT_PROMPT: ClassVar[str] = "Use the following context to answer the question:" DEFAULT_SOURCE_ID: ClassVar[str] = "azure_ai_search" + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: str | None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["semantic"] = "semantic", + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str | None = None, + model: str | None = None, + knowledge_base_name: None = None, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a semantic Azure AI Search context provider. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Name of the search index to query. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"semantic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Name of the semantic configuration in the index. + vector_field_name: Name of the vector field in the index. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Unused in semantic mode. + model: Unused in semantic mode. + knowledge_base_name: Must be ``None`` for this overload. + retrieval_instructions: Unused in semantic mode. + azure_openai_api_key: Unused in semantic mode. + knowledge_base_output_mode: Unused in semantic mode. + retrieval_reasoning_effort: Unused in semantic mode. + agentic_message_history_count: Unused in semantic mode. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: str | None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["agentic"], + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str, + model: str, + knowledge_base_name: None = None, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an agentic provider that creates a Knowledge Base from an index. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Name of the search index used to create the Knowledge Base. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"agentic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Semantic configuration name used by hybrid search operations. + vector_field_name: Vector field name used by hybrid search operations. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base creation. + model: Model used by the generated Knowledge Base. + knowledge_base_name: Must be ``None`` for this overload. + retrieval_instructions: Custom instructions for Knowledge Base retrieval. + azure_openai_api_key: Optional Azure OpenAI API key for Knowledge Base creation. + knowledge_base_output_mode: Output mode for Knowledge Base retrieval. + retrieval_reasoning_effort: Reasoning effort for query planning. + agentic_message_history_count: Number of recent messages included in retrieval. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["agentic"], + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str | None = None, + model: str | None = None, + knowledge_base_name: str, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an agentic provider that connects to an existing Knowledge Base. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Must be ``None`` for this overload. + knowledge_base_name: Name of the existing Knowledge Base to use. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"agentic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Semantic configuration name used by hybrid search operations. + vector_field_name: Vector field name used by hybrid search operations. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Unused when connecting to an existing Knowledge Base. + model: Unused when connecting to an existing Knowledge Base. + retrieval_instructions: Custom instructions for Knowledge Base retrieval. + azure_openai_api_key: Unused when connecting to an existing Knowledge Base. + knowledge_base_output_mode: Output mode for Knowledge Base retrieval. + retrieval_reasoning_effort: Reasoning effort for query planning. + agentic_message_history_count: Number of recent messages included in retrieval. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["agentic"], + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str | None = None, + model: str | None = None, + knowledge_base_name: None = None, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an agentic provider using environment-resolved setup. + + This overload is for agentic initialization where ``index_name`` or + ``knowledge_base_name`` is supplied by ``env_file_path`` or the + ``AZURE_SEARCH_*`` environment variables. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Resolved from ``env_file_path`` or ``AZURE_SEARCH_INDEX_NAME``. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"agentic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Semantic configuration name used by hybrid search operations. + vector_field_name: Vector field name used by hybrid search operations. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Azure OpenAI resource URL when creating a Knowledge Base from an index. + model: Model used when creating a Knowledge Base from an index. + knowledge_base_name: Resolved from ``env_file_path`` or ``AZURE_SEARCH_KNOWLEDGE_BASE_NAME``. + retrieval_instructions: Custom instructions for Knowledge Base retrieval. + azure_openai_api_key: Optional Azure OpenAI API key for Knowledge Base creation. + knowledge_base_output_mode: Output mode for Knowledge Base retrieval. + retrieval_reasoning_effort: Reasoning effort for query planning. + agentic_message_history_count: Number of recent messages included in retrieval. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + def __init__( self, source_id: str = DEFAULT_SOURCE_ID, @@ -163,18 +392,15 @@ class AzureAISearchContextProvider(BaseContextProvider): top_k: int = 5, semantic_configuration_name: str | None = None, vector_field_name: str | None = None, - embedding_function: Callable[[str], Awaitable[list[float]]] - | SupportsGetEmbeddings[str, list[float], Any] - | None = None, + embedding_function: EmbeddingFunction | None = None, context_prompt: str | None = None, azure_openai_resource_url: str | None = None, - model_deployment_name: str | None = None, - model_name: str | None = None, + model: str | None = None, knowledge_base_name: str | None = None, retrieval_instructions: str | None = None, azure_openai_api_key: str | None = None, - knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data", - retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal", + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, env_file_path: str | None = None, env_file_encoding: str | None = None, @@ -184,7 +410,9 @@ class AzureAISearchContextProvider(BaseContextProvider): Args: source_id: Unique identifier for this provider instance. endpoint: Azure AI Search endpoint URL. - index_name: Name of the search index to query. + index_name: Name of the search index to query. In agentic mode, providing this + explicitly selects the index-backed setup and ignores any environment-provided + knowledge base name. api_key: API key for authentication. credential: Azure credential for managed identity authentication. Accepts a TokenCredential, AsyncTokenCredential, or a callable token provider. @@ -195,8 +423,7 @@ class AzureAISearchContextProvider(BaseContextProvider): embedding_function: Async function to generate embeddings or a SupportsGetEmbeddings instance. context_prompt: Custom prompt to prepend to retrieved context. azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base. - model_deployment_name: Model deployment name in Azure OpenAI. - model_name: The underlying model name. + model: Model name to use for Azure OpenAI vectorization. knowledge_base_name: Name of an existing Knowledge Base to use. retrieval_instructions: Custom instructions for Knowledge Base retrieval. azure_openai_api_key: Azure OpenAI API key. @@ -208,12 +435,26 @@ class AzureAISearchContextProvider(BaseContextProvider): """ super().__init__(source_id) - # Determine which fields are required based on mode - required: list[str | tuple[str, ...]] = ["endpoint"] + required: list[str | tuple[str, ...]] + ignored_agentic_field: Literal["index_name", "knowledge_base_name"] | None = None + explicit_index_name = index_name is not None + explicit_knowledge_base_name = knowledge_base_name is not None + if mode == "semantic": - required.append("index_name") - elif mode == "agentic": - required.append(("index_name", "knowledge_base_name")) + required = ["endpoint", "index_name"] + elif explicit_index_name and explicit_knowledge_base_name: + raise SettingNotFoundError( + "Only one of 'index_name', 'knowledge_base_name' may be provided, " + "but multiple were set: 'index_name', 'knowledge_base_name'." + ) + elif explicit_index_name: + required = ["endpoint", "index_name"] + ignored_agentic_field = "knowledge_base_name" + elif explicit_knowledge_base_name: + required = ["endpoint", "knowledge_base_name"] + ignored_agentic_field = "index_name" + else: + required = ["endpoint", ("index_name", "knowledge_base_name")] # Load settings from environment/file settings = load_settings( @@ -227,11 +468,11 @@ class AzureAISearchContextProvider(BaseContextProvider): env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) + if ignored_agentic_field is not None: + settings[ignored_agentic_field] = None - if mode == "agentic" and settings.get("index_name") and not model_deployment_name: - raise ValueError( - "model_deployment_name is required for agentic mode when creating Knowledge Base from index." - ) + if mode == "agentic" and settings.get("index_name") and not model: + raise ValueError("model is required for agentic mode when creating Knowledge Base from index.") resolved_credential: AzureKeyCredential | AsyncTokenCredential if credential: @@ -257,8 +498,7 @@ class AzureAISearchContextProvider(BaseContextProvider): self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT self.azure_openai_resource_url = azure_openai_resource_url - self.azure_openai_deployment_name = model_deployment_name - self.model_name = model_name or model_deployment_name + self.azure_openai_model = model self.knowledge_base_name = settings.get("knowledge_base_name") self.retrieval_instructions = retrieval_instructions self.azure_openai_api_key = azure_openai_api_key @@ -364,7 +604,9 @@ class AzureAISearchContextProvider(BaseContextProvider): if not result_messages: return - context.extend_messages(self.source_id, [Message(role="user", text=self.context_prompt), *result_messages]) + context.extend_messages( + self.source_id, [Message(role="user", contents=[self.context_prompt]), *result_messages] + ) def _find_vector_fields(self, index: Any) -> list[str]: """Find all fields that can store vectors.""" @@ -479,7 +721,7 @@ class AzureAISearchContextProvider(BaseContextProvider): doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType] doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType] if doc_text: - result_messages.append(Message(role="user", text=doc_text)) # type: ignore[reportUnknownArgumentType] + result_messages.append(Message(role="user", contents=[doc_text])) # type: ignore[reportUnknownArgumentType] return result_messages async def _ensure_knowledge_base(self) -> None: @@ -507,8 +749,8 @@ class AzureAISearchContextProvider(BaseContextProvider): raise ValueError("Index client is required when creating Knowledge Base from index") if not self.azure_openai_resource_url: raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index") - if not self.azure_openai_deployment_name: - raise ValueError("model_deployment_name is required when creating Knowledge Base from index") + if not self.azure_openai_model: + raise ValueError("model is required when creating Knowledge Base from index") if not self.index_name: raise ValueError("index_name is required when creating Knowledge Base from index") @@ -527,8 +769,8 @@ class AzureAISearchContextProvider(BaseContextProvider): aoai_params = AzureOpenAIVectorizerParameters( resource_url=self.azure_openai_resource_url, - deployment_name=self.azure_openai_deployment_name, - model_name=self.model_name, + deployment_name=self.azure_openai_model, + model_name=self.azure_openai_model, api_key=self.azure_openai_api_key, ) @@ -711,7 +953,7 @@ class AzureAISearchContextProvider(BaseContextProvider): List of Messages, or a single default Message if no results found. """ if not retrieval_result.response: - return [Message(role="assistant", text="No results found from Knowledge Base.")] + return [Message(role="assistant", contents=["No results found from Knowledge Base."])] annotations = AzureAISearchContextProvider._parse_references_to_annotations(retrieval_result.references) @@ -732,7 +974,7 @@ class AzureAISearchContextProvider(BaseContextProvider): result_messages.append(Message(role=kb_msg.role or "assistant", contents=contents)) if not result_messages: - return [Message(role="assistant", text="No results found from Knowledge Base.")] + return [Message(role="assistant", contents=["No results found from Knowledge Base."])] return result_messages def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str: diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index fc5f1e3b35..d6fe64ba36 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 9972f1301d..64c12e0724 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -155,14 +155,14 @@ class TestInitSemantic: provider = _make_provider(context_prompt="Custom prompt:") assert provider.context_prompt == "Custom prompt:" - def test_model_name_falls_back_to_deployment_name(self) -> None: - """model_name defaults to model_deployment_name when not explicitly set.""" - provider = _make_provider(model_deployment_name="my-deploy") - assert provider.model_name == "my-deploy" + def test_model_is_stored(self) -> None: + """Model is stored on the provider for Azure OpenAI vectorization.""" + provider = _make_provider(model="my-deploy") + assert provider.azure_openai_model == "my-deploy" - def test_model_name_explicit(self) -> None: - provider = _make_provider(model_deployment_name="deploy", model_name="gpt-4") - assert provider.model_name == "gpt-4" + def test_model_explicit(self) -> None: + provider = _make_provider(model="gpt-4") + assert provider.azure_openai_model == "gpt-4" # -- Initialization: credential resolution ------------------------------------ @@ -214,7 +214,7 @@ class TestInitAgenticValidation: knowledge_base_name="kb", api_key="key", mode="agentic", - model_deployment_name="deploy", + model="deploy", azure_openai_resource_url="https://aoai.openai.azure.com", ) @@ -227,8 +227,8 @@ class TestInitAgenticValidation: mode="agentic", ) - def test_missing_model_deployment_name_raises(self) -> None: - with pytest.raises(ValueError, match="model_deployment_name"): + def test_missing_model_raises(self) -> None: + with pytest.raises(ValueError, match="model"): AzureAISearchContextProvider( source_id="s", endpoint="https://test.search.windows.net", @@ -256,7 +256,7 @@ class TestInitAgenticValidation: index_name="idx", api_key="key", mode="agentic", - model_deployment_name="deploy", + model="deploy", ) def test_agentic_with_kb_name_sets_use_existing(self) -> None: @@ -277,12 +277,43 @@ class TestInitAgenticValidation: index_name="idx", api_key="key", mode="agentic", - model_deployment_name="deploy", + model="deploy", azure_openai_resource_url="https://aoai.openai.azure.com", ) assert provider._use_existing_knowledge_base is False assert provider.knowledge_base_name == "idx-kb" + def test_agentic_explicit_kb_ignores_env_index_name(self) -> None: + with patch.dict(os.environ, {"AZURE_SEARCH_INDEX_NAME": "env-index"}, clear=False): + provider = AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + knowledge_base_name="my-kb", + api_key="key", + mode="agentic", + ) + + assert provider.index_name is None + assert provider.knowledge_base_name == "my-kb" + assert provider._use_existing_knowledge_base is True + assert provider._search_client is None + + def test_agentic_explicit_index_ignores_env_kb_name(self) -> None: + with patch.dict(os.environ, {"AZURE_SEARCH_KNOWLEDGE_BASE_NAME": "env-kb"}, clear=False): + provider = AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + index_name="idx", + api_key="key", + mode="agentic", + model="deploy", + azure_openai_resource_url="https://aoai.openai.azure.com", + ) + + assert provider.index_name == "idx" + assert provider.knowledge_base_name == "idx-kb" + assert provider._use_existing_knowledge_base is False + # -- __aenter__ / __aexit__ --------------------------------------------------- @@ -980,9 +1011,9 @@ class TestEnsureKnowledgeBase: provider.knowledge_base_name = "test-kb" provider._index_client = AsyncMock() provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = None + provider.azure_openai_model = None - with pytest.raises(ValueError, match="model_deployment_name is required"): + with pytest.raises(ValueError, match="model is required"): await provider._ensure_knowledge_base() async def test_missing_index_name_raises(self) -> None: @@ -992,7 +1023,7 @@ class TestEnsureKnowledgeBase: provider.knowledge_base_name = "test-kb" provider._index_client = AsyncMock() provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" + provider.azure_openai_model = "deploy" provider.index_name = None with pytest.raises(ValueError, match="index_name is required"): @@ -1006,8 +1037,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" mock_index_client = AsyncMock() @@ -1030,8 +1060,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" mock_index_client = AsyncMock() @@ -1052,8 +1081,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" provider.knowledge_base_output_mode = "answer_synthesis" @@ -1074,8 +1102,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" provider.retrieval_reasoning_effort = "medium" @@ -1343,7 +1370,7 @@ class TestPrepareMessagesForKbSearch: assert len(result) == 0 def test_fallback_to_msg_text_when_no_contents(self) -> None: - msg = Message(role="user", text="fallback text") + msg = Message(role="user", contents=["fallback text"]) result = AzureAISearchContextProvider._prepare_messages_for_kb_search([msg]) assert len(result) == 1 assert result[0].content[0].text == "fallback text" diff --git a/python/packages/azure-ai/AGENTS.md b/python/packages/azure-ai/AGENTS.md deleted file mode 100644 index 1907ae1854..0000000000 --- a/python/packages/azure-ai/AGENTS.md +++ /dev/null @@ -1,32 +0,0 @@ -# Azure AI Package (agent-framework-azure-ai) - -Integration with Azure AI Foundry for persistent agents and project-based agent management. - -## Main Classes - -- **`AzureAIAgentClient`** - Chat client for Azure AI Agents (persistent agents with threads) -- **`AzureAIClient`** - Client for Azure AI Foundry project-based agents -- **`AzureAIAgentsProvider`** - Provider for listing/managing Azure AI agents -- **`AzureAIProjectAgentProvider`** - Provider for project-scoped agent management -- **`AzureAISettings`** - Pydantic settings for Azure AI configuration -- **`AzureAIAgentOptions`** / **`AzureAIProjectAgentOptions`** - Options TypedDicts - -## Usage - -```python -from agent_framework.azure import AzureAIAgentClient - -client = AzureAIAgentClient( - endpoint="https://your-project.services.ai.azure.com", - agent_id="your-agent-id", -) -response = await client.get_response("Hello") -``` - -## Import Path - -```python -from agent_framework.azure import AzureAIAgentClient, AzureAIClient -# or directly: -from agent_framework_azure_ai import AzureAIAgentClient -``` diff --git a/python/packages/azure-ai/LICENSE b/python/packages/azure-ai/LICENSE deleted file mode 100644 index 9e841e7a26..0000000000 --- a/python/packages/azure-ai/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/azure-ai/README.md b/python/packages/azure-ai/README.md deleted file mode 100644 index 34dedd5500..0000000000 --- a/python/packages/azure-ai/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Get Started with Microsoft Agent Framework Azure AI - -Please install this package via pip: - -```bash -pip install agent-framework-azure-ai --pre -``` - -## Foundry Memory Context Provider - -The Foundry Memory context provider enables semantic memory capabilities for your agents using Azure AI Foundry Memory Store. It automatically: -- Retrieves static (user profile) memories on first run -- Searches for contextual memories based on conversation -- Updates the memory store with new conversation messages - -### Basic Usage Example - -See the [Foundry Memory example](../../samples/02-agents/context_providers/azure_ai_foundry_memory.py) which demonstrates: - -- Creating a memory store using Azure AI Projects client -- Setting up an agent with FoundryMemoryProvider -- Teaching the agent user preferences -- Retrieving information using remembered context across conversations -- Automatic memory updates with configurable delays - -and see the [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information. diff --git a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py deleted file mode 100644 index 401af22c51..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import importlib.metadata - -from ._agent_provider import AzureAIAgentsProvider # pyright: ignore[reportDeprecated] -from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated] -from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient # pyright: ignore[reportDeprecated] -from ._deprecated_azure_openai import ( - AzureOpenAIAssistantsClient, # pyright: ignore[reportDeprecated] - AzureOpenAIAssistantsOptions, - AzureOpenAIChatClient, # pyright: ignore[reportDeprecated] - AzureOpenAIChatOptions, - AzureOpenAIConfigMixin, - AzureOpenAIEmbeddingClient, # pyright: ignore[reportDeprecated] - AzureOpenAIResponsesClient, # pyright: ignore[reportDeprecated] - AzureOpenAIResponsesOptions, - AzureOpenAISettings, - AzureUserSecurityContext, -) -from ._embedding_client import ( - AzureAIInferenceEmbeddingClient, - AzureAIInferenceEmbeddingOptions, - AzureAIInferenceEmbeddingSettings, - RawAzureAIInferenceEmbeddingClient, -) -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._project_provider import AzureAIProjectAgentProvider # pyright: ignore[reportDeprecated] -from ._shared import AzureAISettings - -try: - __version__ = importlib.metadata.version(__name__) -except importlib.metadata.PackageNotFoundError: - __version__ = "0.0.0" - -__all__ = [ - "AzureAIAgentClient", - "AzureAIAgentOptions", - "AzureAIAgentsProvider", - "AzureAIClient", - "AzureAIInferenceEmbeddingClient", - "AzureAIInferenceEmbeddingOptions", - "AzureAIInferenceEmbeddingSettings", - "AzureAIProjectAgentOptions", - "AzureAIProjectAgentProvider", - "AzureAISettings", - "AzureCredentialTypes", - "AzureOpenAIAssistantsClient", - "AzureOpenAIAssistantsOptions", - "AzureOpenAIChatClient", - "AzureOpenAIChatOptions", - "AzureOpenAIConfigMixin", - "AzureOpenAIEmbeddingClient", - "AzureOpenAIResponsesClient", - "AzureOpenAIResponsesOptions", - "AzureOpenAISettings", - "AzureTokenProvider", - "AzureUserSecurityContext", - "RawAzureAIClient", - "RawAzureAIInferenceEmbeddingClient", - "__version__", -] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py deleted file mode 100644 index a43702d8b3..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py +++ /dev/null @@ -1,558 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -import warnings -from collections.abc import Callable, Sequence -from typing import Any, Generic, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - BaseContextProvider, - FunctionTool, - MiddlewareTypes, - normalize_tools, -) -from agent_framework._mcp import MCPTool -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from azure.ai.agents.aio import AgentsClient -from azure.ai.agents.models import Agent as AzureAgent -from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType -from pydantic import BaseModel - -from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated] -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, to_azure_ai_agent_tools - -if sys.version_info >= (3, 13): - from typing import Self, TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 13): - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - - -# Type variable for options - allows typed Agent[TOptions] returns -# Default matches AzureAIAgentClient's default options type -OptionsCoT = TypeVar( - "OptionsCoT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIAgentOptions", - covariant=True, -) - - -@deprecated( - "AzureAIAgentClient and the AzureAIAgentsProvider are deprecated. " - "They target the V1 Agents Service API and have no direct replacement; " - "for new Foundry projects, use FoundryAgent." -) -class AzureAIAgentsProvider(Generic[OptionsCoT]): - """Provider for Azure AI Agent Service V1 (Persistent Agents API). - - .. deprecated:: - AzureAIAgentsProvider is deprecated and will be removed in a future release. - Use :class:`AzureAIProjectAgentProvider` instead for the V2 (Projects/Responses) API. - - This provider enables creating, retrieving, and wrapping Azure AI agents as Agent - instances. It manages the underlying AgentsClient lifecycle and provides a high-level - interface for agent operations. - - The provider can be initialized with either: - - An existing AgentsClient instance - - Azure credentials and endpoint for automatic client creation - - Examples: - Using credentials (auto-creates client): - - .. code-block:: python - - from agent_framework.azure import AzureAIAgentsProvider - from azure.identity.aio import AzureCliCredential - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyAgent", - instructions="You are a helpful assistant.", - ) - result = await agent.run("Hello!") - - Using existing AgentsClient: - - .. code-block:: python - - from agent_framework.azure import AzureAIAgentsProvider - from azure.ai.agents.aio import AgentsClient - - async with AgentsClient(endpoint=endpoint, credential=credential) as client: - provider = AzureAIAgentsProvider(agents_client=client) - agent = await provider.create_agent(name="MyAgent", instructions="...") - """ - - def __init__( - self, - agents_client: AgentsClient | None = None, - *, - project_endpoint: str | None = None, - credential: AzureCredentialTypes | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize the Azure AI Agents Provider. - - Args: - agents_client: An existing AgentsClient to use. If provided, the provider - will not manage its lifecycle. - - Keyword Args: - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via AZURE_AI_PROJECT_ENDPOINT environment variable. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - Required if agents_client is not provided. - env_file_path: Path to .env file for loading settings. - env_file_encoding: Encoding of the .env file. - - Raises: - ValueError: If required parameters are missing or invalid. - """ - warnings.warn( - "AzureAIAgentsProvider is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider instead for the V2 (Projects/Responses) API.", - DeprecationWarning, - stacklevel=2, - ) - self._settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - self._should_close_client = False - - if agents_client is not None: - self._agents_client = agents_client - else: - resolved_endpoint = self._settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Provide 'project_endpoint' parameter " - "or set 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - if not credential: - raise ValueError("Azure credential is required when agents_client is not provided.") - self._agents_client = AgentsClient( - endpoint=resolved_endpoint, - credential=credential, # type: ignore[arg-type] - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - self._should_close_client = True - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: Any, - ) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the provider and release resources. - - Only closes the AgentsClient if it was created by this provider. - """ - if self._should_close_client: - await self._agents_client.close() - - async def create_agent( - self, - name: str, - *, - model: str | None = None, - instructions: str | None = None, - description: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a new agent on the Azure AI service and return a Agent. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - Use :meth:`AzureAIProjectAgentProvider.create_agent` instead. - - This method creates a persistent agent on the Azure AI service with the specified - configuration and returns a local Agent instance for interaction. - - Args: - name: The name for the agent. - - Keyword Args: - model: The model deployment name to use. Falls back to - AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable if not provided. - instructions: Instructions for the agent's behavior. - description: A description of the agent's purpose. - tools: Tools to make available to the agent. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the created agent. - - Raises: - ValueError: If model deployment name is not available. - - Examples: - .. code-block:: python - - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather assistant.", - tools=get_weather, - ) - """ - warnings.warn( - "AzureAIAgentsProvider.create_agent() is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider.create_agent() instead.", - DeprecationWarning, - stacklevel=2, - ) - resolved_model = model or self._settings.get("model_deployment_name") - if not resolved_model: - raise ValueError( - "Model deployment name is required. Provide 'model' parameter " - "or set 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable." - ) - - # Extract response_format from default_options if present - opts = dict(default_options) if default_options else {} - response_format = opts.get("response_format") - - args: dict[str, Any] = { - "model": resolved_model, - "name": name, - } - - if description: - args["description"] = description - if instructions: - args["instructions"] = instructions - - # Handle response format - if response_format and isinstance(response_format, type) and issubclass(response_format, BaseModel): - args["response_format"] = self._create_response_format_config(response_format) - - # Normalize and convert tools - # Local MCP tools (MCPTool) are handled by Agent at runtime, not stored on the Azure agent - normalized_tools = normalize_tools(tools) - if normalized_tools: - # Collect all non-MCP tools for Azure AI agent creation. - # to_azure_ai_agent_tools handles FunctionTool, SDK Tool types (FileSearchTool, etc.), and dicts. - non_mcp_tools: list[Any] = [t for t in normalized_tools if not isinstance(t, MCPTool)] - if non_mcp_tools: - # Pass run_options to capture tool_resources (e.g., for file search vector stores) - run_options: dict[str, Any] = {} - args["tools"] = to_azure_ai_agent_tools(non_mcp_tools, run_options) - if "tool_resources" in run_options: - args["tool_resources"] = run_options["tool_resources"] - - # Create the agent on the service - created_agent = await self._agents_client.create_agent(**args) - - # Create Agent wrapper - return self._to_chat_agent_from_agent( - created_agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - async def get_agent( - self, - id: str, - *, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Retrieve an existing agent from the service and return a Agent. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - Use :meth:`AzureAIProjectAgentProvider.get_agent` instead. - - This method fetches an agent by ID from the Azure AI service - and returns a local Agent instance for interaction. - - Args: - id: The ID of the agent to retrieve from the service. - - Keyword Args: - tools: Tools to make available to the agent. Required if the agent - has function tools that need implementations. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the retrieved agent. - - Raises: - ValueError: If required function tools are not provided. - - Examples: - .. code-block:: python - - agent = await provider.get_agent("agent-123") - - # With function tools - agent = await provider.get_agent("agent-123", tools=my_function) - """ - warnings.warn( - "AzureAIAgentsProvider.get_agent() is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider.get_agent() instead.", - DeprecationWarning, - stacklevel=2, - ) - agent = await self._agents_client.get_agent(id) - - # Validate function tools - normalized_tools = normalize_tools(tools) - self._validate_function_tools(agent.tools, normalized_tools) - - return self._to_chat_agent_from_agent( - agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def as_agent( - self, - agent: AzureAgent, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Wrap an existing Agent SDK object as a Agent without making HTTP calls. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - Use :meth:`AzureAIProjectAgentProvider.as_agent` instead. - - Use this method when you already have an Agent object from a previous - SDK operation and want to use it with the Agent Framework. - - Args: - agent: The Agent object to wrap. - tools: Tools to make available to the agent. Required if the agent - has function tools that need implementations. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the agent. - - Raises: - ValueError: If required function tools are not provided. - - Examples: - .. code-block:: python - - # Create agent directly with SDK - sdk_agent = await agents_client.create_agent( - model="gpt-4", - name="MyAgent", - instructions="...", - ) - - # Wrap as Agent - chat_agent = provider.as_agent(sdk_agent) - """ - warnings.warn( - "AzureAIAgentsProvider.as_agent() is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider.as_agent() instead.", - DeprecationWarning, - stacklevel=2, - ) - # Validate function tools - normalized_tools = normalize_tools(tools) - self._validate_function_tools(agent.tools, normalized_tools) - - return self._to_chat_agent_from_agent( - agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def _to_chat_agent_from_agent( - self, - agent: AzureAgent, - provided_tools: Sequence[ToolTypes] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a Agent from an Agent SDK object. - - Args: - agent: The Agent SDK object. - provided_tools: User-provided tools (including function implementations). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - """ - # Create the underlying client - client = AzureAIAgentClient( # pyright: ignore[reportDeprecated] - agents_client=self._agents_client, - agent_id=agent.id, - agent_name=agent.name, - agent_description=agent.description, - should_cleanup_agent=False, # Provider manages agent lifecycle - ) - - # Merge tools: convert agent's hosted tools + user-provided function tools - merged_tools = self._merge_tools(agent.tools, provided_tools) - merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} - merged_default_options.setdefault("model_id", agent.model) - - return Agent( # type: ignore[return-value] - client=client, - id=agent.id, - name=agent.name, - description=agent.description, - instructions=agent.instructions, - tools=merged_tools, - default_options=cast(Any, merged_default_options), - middleware=middleware, - context_providers=context_providers, - ) - - def _merge_tools( - self, - agent_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolTypes] | None, - ) -> list[ToolTypes]: - """Merge hosted tools from agent with user-provided function tools. - - Args: - agent_tools: Tools from the agent definition (Azure AI format). - provided_tools: User-provided tools (Agent Framework format). - - Returns: - Combined list of tools for the Agent. - """ - merged: list[ToolTypes] = [] - - # Hosted tools (file_search, code_interpreter, bing_grounding, openapi, etc.) - # are already defined on the server agent and will be read back by the client - # at run time via agent_definition.tools. We skip them here to avoid sending - # them again at request time (which causes API errors like unknown vector_store_ids). - - # Add user-provided function tools and MCP tools - if provided_tools: - for provided_tool in provided_tools: - # FunctionTool - has implementation for function calling - # MCPTool - Agent handles MCP connection and tool discovery at runtime - if isinstance(provided_tool, (FunctionTool, MCPTool)): - merged.append(provided_tool) # type: ignore[reportUnknownArgumentType] - - return merged - - def _validate_function_tools( - self, - agent_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolTypes] | None, - ) -> None: - """Validate that required function tools are provided. - - Raises: - ValueError: If agent has function tools but user - didn't provide implementations. - """ - if not agent_tools: - return - - # Get function tool names from agent definition - function_tool_names: set[str] = set() - for tool in agent_tools: - if isinstance(tool, dict): - tool_dict = cast(dict[str, Any], tool) - if tool_dict.get("type") == "function": - func_def = cast(dict[str, Any], tool_dict.get("function", {})) - name = func_def.get("name") - if isinstance(name, str): - function_tool_names.add(name) - elif hasattr(tool, "type") and tool.type == "function": - func_attr = getattr(tool, "function", None) - if func_attr and hasattr(func_attr, "name"): - function_tool_names.add(str(func_attr.name)) - - if not function_tool_names: - return - - # Get provided function names - provided_names: set[str] = set() - if provided_tools: - for tool in provided_tools: - if isinstance(tool, FunctionTool): - provided_names.add(tool.name) - - # Check for missing implementations - missing = function_tool_names - provided_names - if missing: - raise ValueError( - f"Agent has function tools that require implementations: {missing}. " - "Provide these functions via the 'tools' parameter." - ) - - def _create_response_format_config( - self, - response_format: type[BaseModel], - ) -> ResponseFormatJsonSchemaType: - """Create response format configuration for Azure AI. - - Args: - response_format: Pydantic model for structured output. - - Returns: - Azure AI response format configuration. - """ - return ResponseFormatJsonSchemaType( - json_schema=ResponseFormatJsonSchema( - name=response_format.__name__, - schema=response_format.model_json_schema(), - ) - ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py deleted file mode 100644 index 7faba8c47c..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ /dev/null @@ -1,1557 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import ast -import json -import logging -import os -import re -import sys -import warnings -from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, TypedDict, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - Annotation, - BaseChatClient, - BaseContextProvider, - ChatAndFunctionMiddlewareTypes, - ChatMiddlewareLayer, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - Message, - MiddlewareTypes, - ResponseStream, - Role, - TextSpanRegion, - UsageDetails, -) -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from agent_framework.exceptions import ( - ChatClientException, - ChatClientInvalidRequestException, -) -from agent_framework.observability import ChatTelemetryLayer -from azure.ai.agents.aio import AgentsClient -from azure.ai.agents.models import ( - Agent as AzureAgent, -) -from azure.ai.agents.models import ( - AgentsNamedToolChoice, - AgentsNamedToolChoiceType, - AgentsToolChoiceOptionMode, - AgentStreamEvent, - AsyncAgentEventHandler, - AsyncAgentRunStream, - BingCustomSearchTool, - BingGroundingTool, - CodeInterpreterTool, - FileSearchTool, - FunctionName, - FunctionToolDefinition, - ListSortOrder, - McpTool, - MessageDeltaChunk, - MessageDeltaTextContent, - MessageDeltaTextFileCitationAnnotation, - MessageDeltaTextFilePathAnnotation, - MessageDeltaTextUrlCitationAnnotation, - MessageImageUrlParam, - MessageInputContentBlock, - MessageInputImageUrlBlock, - MessageInputTextBlock, - MessageRole, - RequiredFunctionToolCall, - RequiredMcpToolCall, - ResponseFormatJsonSchema, - ResponseFormatJsonSchemaType, - RunStatus, - RunStep, - RunStepDeltaChunk, - RunStepDeltaCodeInterpreterImageOutput, - RunStepDeltaCodeInterpreterLogOutput, - RunStepDeltaToolCall, - SubmitToolApprovalAction, - SubmitToolOutputsAction, - ThreadMessageOptions, - ThreadRun, - ToolApproval, - ToolDefinition, - ToolOutput, - VectorStoreDataSource, -) -from pydantic import BaseModel - -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, resolve_file_ids, to_azure_ai_agent_tools - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore[import] # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - - -logger = logging.getLogger("agent_framework.azure") - -__all__ = ["AzureAIAgentClient", "AzureAIAgentOptions"] - - -# region Azure AI Agent Options TypedDict - - -class AzureAIAgentOptions(ChatOptions, total=False): - """Azure AI Foundry Agent Service-specific options dict. - - .. deprecated:: - AzureAIAgentOptions is deprecated and will be removed in a future release. - Use :class:`AzureAIProjectAgentOptions` instead for the V2 (Projects/Responses) API. - - Extends base ChatOptions with Azure AI Agent Service parameters. - Azure AI Agents provides a managed agent runtime with built-in - tools for code interpreter, file search, and web search. - - See: https://learn.microsoft.com/azure/ai-services/agents/ - - Keys: - # Inherited from ChatOptions: - model_id: The model deployment name, - translates to ``model`` in Azure AI API. - temperature: Sampling temperature between 0 and 2. - top_p: Nucleus sampling parameter. - max_tokens: Maximum number of tokens to generate, - translates to ``max_completion_tokens`` in Azure AI API. - tools: List of tools available to the agent. - tool_choice: How the model should use tools. - allow_multiple_tool_calls: Whether to allow parallel tool calls, - translates to ``parallel_tool_calls`` in Azure AI API. - response_format: Structured output schema. - metadata: Request metadata for tracking. - instructions: System instructions for the agent. - - # Options not supported in Azure AI Agent Service: - stop: Not supported. - seed: Not supported. - frequency_penalty: Not supported. - presence_penalty: Not supported. - user: Not supported. - store: Not supported. - logit_bias: Not supported. - - # Azure AI Agent-specific options: - conversation_id: Thread ID to continue conversation in. - tool_resources: Resources for tools (file IDs, vector stores). - """ - - # Azure AI Agent-specific options - conversation_id: str # type: ignore[misc] - """Thread ID to continue a conversation in an existing thread.""" - - tool_resources: dict[str, Any] - """Tool-specific resources for code_interpreter and file_search. - For code_interpreter: {"file_ids": ["file-abc123"]} - For file_search: {"vector_store_ids": ["vs-abc123"]} - """ - - # ChatOptions fields not supported in Azure AI Agent Service - stop: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - seed: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - frequency_penalty: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - presence_penalty: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - user: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - store: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - logit_bias: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - -AZURE_AI_AGENT_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", - "max_tokens": "max_completion_tokens", - "allow_multiple_tool_calls": "parallel_tool_calls", -} -"""Maps ChatOptions keys to Azure AI Agents API parameter names.""" - -AzureAIAgentOptionsT = TypeVar( - "AzureAIAgentOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIAgentOptions", - covariant=True, -) - - -# endregion - - -@deprecated( - "AzureAIAgentClient is deprecated. " - "It targets the V1 Agents Service API and has no direct replacement; " - "for new Foundry projects, use FoundryAgent." -) -class AzureAIAgentClient( - FunctionInvocationLayer[AzureAIAgentOptionsT], - ChatMiddlewareLayer[AzureAIAgentOptionsT], - ChatTelemetryLayer[AzureAIAgentOptionsT], - BaseChatClient[AzureAIAgentOptionsT], - Generic[AzureAIAgentOptionsT], -): - """Azure AI Agent Chat client with middleware, telemetry, and function invocation support. - - .. deprecated:: - AzureAIAgentClient is deprecated and will be removed in a future release. - It targets the V1 Agents Service API and has no direct replacement. - For new Foundry projects, use :class:`FoundryAgent`. - """ - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] - STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] - - # region Hosted Tool Factory Methods - - @staticmethod - def get_code_interpreter_tool( - *, - file_ids: list[str | Content] | None = None, - data_sources: list[VectorStoreDataSource] | None = None, - ) -> CodeInterpreterTool: - """Create a code interpreter tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - Keyword Args: - file_ids: List of uploaded file IDs or Content objects to make available to - the code interpreter. Accepts plain strings or Content.from_hosted_file() - instances. The underlying SDK raises ValueError if both file_ids and - data_sources are provided. - data_sources: List of vector store data sources for enterprise file search. - Mutually exclusive with file_ids. - - Returns: - A CodeInterpreterTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - # Basic code interpreter - tool = AzureAIAgentClient.get_code_interpreter_tool() - - # With uploaded file IDs - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-abc123"]) - - # With Content objects - from agent_framework import Content - - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=[Content.from_hosted_file("file-abc123")]) - - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_code_interpreter_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - resolved = resolve_file_ids(file_ids) - return CodeInterpreterTool(file_ids=resolved, data_sources=data_sources) - - @staticmethod - def get_file_search_tool( - *, - vector_store_ids: list[str], - ) -> FileSearchTool: - """Create a file search tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - Keyword Args: - vector_store_ids: List of vector store IDs to search within. - - Returns: - A FileSearchTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - tool = AzureAIAgentClient.get_file_search_tool( - vector_store_ids=["vs_abc123"], - ) - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_file_search_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - return FileSearchTool(vector_store_ids=vector_store_ids) - - @staticmethod - def get_web_search_tool( - *, - bing_connection_id: str | None = None, - bing_custom_connection_id: str | None = None, - bing_custom_instance_id: str | None = None, - ) -> BingGroundingTool | BingCustomSearchTool: - """Create a web search tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - For Azure AI Agents, web search uses Bing Grounding or Bing Custom Search. - If no arguments are provided, attempts to read from environment variables. - If no connection IDs are found, raises ValueError. - - Keyword Args: - bing_connection_id: The Bing Grounding connection ID for standard web search. - Falls back to BING_CONNECTION_ID environment variable. - bing_custom_connection_id: The Bing Custom Search connection ID. - Falls back to BING_CUSTOM_CONNECTION_ID environment variable. - bing_custom_instance_id: The Bing Custom Search instance ID. - Falls back to BING_CUSTOM_INSTANCE_NAME environment variable. - - Returns: - A BingGroundingTool or BingCustomSearchTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - # Bing Grounding (explicit) - tool = AzureAIAgentClient.get_web_search_tool( - bing_connection_id="conn_bing_123", - ) - - # Bing Grounding (from environment variable) - tool = AzureAIAgentClient.get_web_search_tool() - - # Bing Custom Search (explicit) - tool = AzureAIAgentClient.get_web_search_tool( - bing_custom_connection_id="conn_custom_123", - bing_custom_instance_id="instance_456", - ) - - # Bing Custom Search (from environment variables) - # Set BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME - tool = AzureAIAgentClient.get_web_search_tool() - - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_web_search_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - # Try explicit Bing Custom Search parameters first, then environment variables - resolved_custom_connection = bing_custom_connection_id or os.environ.get("BING_CUSTOM_CONNECTION_ID") - resolved_custom_instance = bing_custom_instance_id or os.environ.get("BING_CUSTOM_INSTANCE_NAME") - - if resolved_custom_connection and resolved_custom_instance: - return BingCustomSearchTool( - connection_id=resolved_custom_connection, - instance_name=resolved_custom_instance, - ) - - # Try explicit Bing Grounding parameter first, then environment variable - resolved_connection_id = bing_connection_id or os.environ.get("BING_CONNECTION_ID") - if resolved_connection_id: - return BingGroundingTool(connection_id=resolved_connection_id) - - # Azure AI Agents requires Bing connection for web search - raise ValueError( - "Azure AI Agents requires a Bing connection for web search. " - "Provide bing_connection_id (or set BING_CONNECTION_ID env var) for Bing Grounding, " - "or provide both bing_custom_connection_id and bing_custom_instance_id " - "(or set BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME env vars) for Bing Custom Search." - ) - - @staticmethod - def get_mcp_tool( - *, - name: str, - url: str | None = None, - description: str | None = None, - approval_mode: str | dict[str, list[str]] | None = None, - allowed_tools: list[str] | None = None, - headers: dict[str, str] | None = None, - ) -> McpTool: - """Create a hosted MCP tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - This configures an MCP (Model Context Protocol) server that will be called - by Azure AI's service. The tools from this MCP server are executed remotely - by Azure AI, not locally by your application. - - Note: - For local MCP execution where your application calls the MCP server - directly, use the MCP client tools instead of this method. - - Keyword Args: - name: A label/name for the MCP server. - url: The URL of the MCP server. - description: A description of what the MCP server provides. - approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools, - or provide a dict with "always_require_approval" and/or "never_require_approval" - keys mapping to lists of tool names. - allowed_tools: List of tool names that are allowed to be used from this MCP server. - headers: HTTP headers to include in requests to the MCP server. - - Returns: - An McpTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - tool = AzureAIAgentClient.get_mcp_tool( - name="my_mcp", - url="https://mcp.example.com", - ) - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_mcp_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - mcp_tool = McpTool( - server_label=name.replace(" ", "_"), - server_url=url or "", - allowed_tools=list(allowed_tools) if allowed_tools else [], - ) - - # Set approval mode if provided - # The SDK's set_approval_mode() accepts dict at runtime even though type hints say str. - if approval_mode: - if isinstance(approval_mode, str): - if approval_mode == "never_require": - mcp_tool.set_approval_mode("never") - elif approval_mode == "always_require": - mcp_tool.set_approval_mode("always") - else: - mcp_tool.set_approval_mode(approval_mode) - elif isinstance(approval_mode, dict): - # Handle dict-based approval mode (per-tool approval settings) - if "never_require_approval" in approval_mode: - mcp_tool.set_approval_mode({"never": {"tool_names": approval_mode["never_require_approval"]}}) # type: ignore[arg-type] - elif "always_require_approval" in approval_mode: - mcp_tool.set_approval_mode({"always": {"tool_names": approval_mode["always_require_approval"]}}) # type: ignore[arg-type] - - # Set headers if provided - if headers: - for key, value in headers.items(): - mcp_tool.update_headers(key, value) - - return mcp_tool - - # endregion - - def __init__( - self, - *, - agents_client: AgentsClient | None = None, - agent_id: str | None = None, - agent_name: str | None = None, - agent_description: str | None = None, - thread_id: str | None = None, - project_endpoint: str | None = None, - model_deployment_name: str | None = None, - credential: AzureCredentialTypes | None = None, - should_cleanup_agent: bool = True, - additional_properties: dict[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure AI Agent client. - - Keyword Args: - agents_client: An existing AgentsClient to use. If not provided, one will be created. - agent_id: The ID of an existing agent to use. If not provided and agents_client is provided, - a new agent will be created (and deleted after the request). If neither agents_client - nor agent_id is provided, both will be created and managed automatically. - agent_name: The name to use when creating new agents. - agent_description: The description to use when creating new agents. - thread_id: Default thread ID to use for conversations. Can be overridden by - conversation_id property when making a request. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a agents_client is passed. - model_deployment_name: The model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - should_cleanup_agent: Whether to cleanup (delete) agents created by this client when - the client is closed or context is exited. Defaults to True. Only affects agents - created by this client instance; existing agents passed via agent_id are never deleted. - additional_properties: Additional properties stored on the client instance. - middleware: Optional sequence of middlewares to include. - function_invocation_configuration: Optional function invocation configuration. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Examples: - .. code-block:: python - - from agent_framework_azure_ai import AzureAIAgentClient - from azure.identity.aio import DefaultAzureCredential - - # Using environment variables - # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME= - credential = DefaultAzureCredential() - client = AzureAIAgentClient(credential=credential) - - # Or passing parameters directly - client = AzureAIAgentClient( - project_endpoint="https://your-project.cognitiveservices.azure.com", - model_deployment_name="", - credential=credential, - ) - - # Or loading from a .env file - client = AzureAIAgentClient(credential=credential, env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework_azure_ai import AzureAIAgentOptions - - - class MyOptions(AzureAIAgentOptions, total=False): - my_custom_option: str - - - client: AzureAIAgentClient[MyOptions] = AzureAIAgentClient(credential=credential) - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - # If no agents_client is provided, create one - should_close_client = False - if agents_client is None: - resolved_endpoint = azure_ai_settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " - "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - - if agent_id is None and not azure_ai_settings.get("model_deployment_name"): - raise ValueError( - "Azure AI model deployment name is required. Set via 'model_deployment_name' parameter " - "or 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable." - ) - - # Use provided credential - if not credential: - raise ValueError("Azure credential is required when agents_client is not provided.") - agents_client = AgentsClient( - endpoint=resolved_endpoint, - credential=credential, # type: ignore[arg-type] - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - should_close_client = True - - # Initialize parent - super().__init__( - additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - - # Initialize instance variables - self.agents_client = agents_client - self.credential = credential - self.agent_id = agent_id - self.agent_name = agent_name - self.agent_description = agent_description - self.model_id = azure_ai_settings.get("model_deployment_name") - self.thread_id = thread_id - self.should_cleanup_agent = should_cleanup_agent # Track whether we should delete the agent - self._agent_created = False # Track whether agent was created inside this class - self._should_close_client = should_close_client # Track whether we should close client connection - self._agent_definition: AzureAgent | None = None # Cached definition for existing agent - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit - clean up any agents we created.""" - await self.close() - - async def close(self) -> None: - """Close the agents_client and clean up any agents we created.""" - await self._cleanup_agent_if_needed() - await self._close_client_if_needed() - - @override - def _inner_get_response( - self, - *, - messages: Sequence[Message], - options: Mapping[str, Any], - stream: bool = False, - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - if stream: - # Streaming mode - return the async generator directly - async def _stream() -> AsyncIterable[ChatResponseUpdate]: - # prepare - run_options, required_action_results = await self._prepare_options(messages, options, **kwargs) - agent_id = await self._get_agent_id_or_create(run_options) - - # execute and process - async for update in self._process_stream( - *(await self._create_agent_stream(agent_id, run_options, required_action_results)) - ): - yield update - - return self._build_response_stream(_stream(), response_format=options.get("response_format")) - - # Non-streaming mode - collect updates and convert to response - async def _get_response() -> ChatResponse: - async def _get_streaming() -> AsyncIterable[ChatResponseUpdate]: - # prepare - run_options, required_action_results = await self._prepare_options(messages, options, **kwargs) - agent_id = await self._get_agent_id_or_create(run_options) - - # execute and process - async for update in self._process_stream( - *(await self._create_agent_stream(agent_id, run_options, required_action_results)) - ): - yield update - - return await ChatResponse.from_update_generator( - updates=_get_streaming(), - output_format_type=options.get("response_format"), - ) - - return _get_response() - - async def _get_agent_id_or_create(self, run_options: dict[str, Any] | None = None) -> str: - """Determine which agent to use and create if needed. - - Returns: - str: The agent_id to use - """ - run_options = run_options or {} - # If no agent_id is provided, create a temporary agent - if self.agent_id is None: - if "model" not in run_options or not run_options["model"]: - raise ValueError( - "Model deployment name is required for agent creation, " - "can also be passed to the get_response methods." - ) - - agent_name: str = self.agent_name or "UnnamedAgent" - args: dict[str, Any] = { - "model": run_options["model"], - "name": agent_name, - "description": self.agent_description, - } - if "tools" in run_options: - args["tools"] = run_options["tools"] - if "tool_resources" in run_options: - args["tool_resources"] = run_options["tool_resources"] - if "instructions" in run_options: - args["instructions"] = run_options["instructions"] - if "response_format" in run_options: - args["response_format"] = run_options["response_format"] - - if "temperature" in run_options: - args["temperature"] = run_options["temperature"] - if "top_p" in run_options: - args["top_p"] = run_options["top_p"] - - created_agent = await self.agents_client.create_agent(**args) - - self.agent_id = str(created_agent.id) - self._agent_definition = created_agent - self._agent_created = True - - return self.agent_id - - async def _create_agent_stream( - self, - agent_id: str, - run_options: dict[str, Any], - required_action_results: list[Content] | None, - ) -> tuple[AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], str]: - """Create the agent stream for processing. - - Returns: - tuple: (stream, final_thread_id) - """ - thread_id = run_options.pop("thread_id", None) - - # Get any active run for this thread - thread_run = await self._get_active_thread_run(thread_id) - - stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any] - handler: AsyncAgentEventHandler[Any] = AsyncAgentEventHandler() - tool_run_id, tool_outputs, tool_approvals = self._prepare_tool_outputs_for_azure_ai(required_action_results) - - if ( - thread_run is not None - and tool_run_id is not None - and tool_run_id == thread_run.id - and (tool_outputs or tool_approvals) - ): # type: ignore[reportUnknownMemberType] - # There's an active run and we have tool results to submit, so submit the results. - args: dict[str, Any] = { - "thread_id": thread_run.thread_id, - "run_id": tool_run_id, - "event_handler": handler, - } - if tool_outputs: - args["tool_outputs"] = tool_outputs - if tool_approvals: - args["tool_approvals"] = tool_approvals - await self.agents_client.runs.submit_tool_outputs_stream(**args) # type: ignore[reportUnknownMemberType] - # Pass the handler to the stream to continue processing - stream = handler - final_thread_id = thread_run.thread_id - else: - # Handle thread creation or cancellation - final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options) - - # Now create a new run and stream the results. - run_options.pop("conversation_id", None) - stream = await self.agents_client.runs.stream( # type: ignore[reportUnknownMemberType] - final_thread_id, agent_id=agent_id, **run_options - ) - - return stream, final_thread_id - - async def _get_active_thread_run(self, thread_id: str | None) -> ThreadRun | None: - """Get any active run for the given thread.""" - if thread_id is None: - return None - - async for run in self.agents_client.runs.list(thread_id=thread_id, limit=1, order=ListSortOrder.DESCENDING): # type: ignore[reportUnknownMemberType] - if run.status not in [ - RunStatus.COMPLETED, - RunStatus.CANCELLED, - RunStatus.FAILED, - RunStatus.EXPIRED, - ]: - return run - return None - - async def _prepare_thread( - self, thread_id: str | None, thread_run: ThreadRun | None, run_options: dict[str, Any] - ) -> str: - """Prepare the thread for a new run, creating or cleaning up as needed.""" - if thread_id is not None: - if thread_run is not None: - # There was an active run; we need to cancel it before starting a new run. - await self.agents_client.runs.cancel(thread_id, thread_run.id) - - return thread_id - - # No thread ID was provided, so create a new thread. - thread = await self.agents_client.threads.create( - tool_resources=run_options.get("tool_resources"), - metadata=run_options.get("metadata"), - messages=run_options.get("additional_messages"), - ) - return thread.id - - def _extract_url_citations( - self, message_delta_chunk: MessageDeltaChunk, azure_search_tool_calls: list[dict[str, Any]] - ) -> list[Annotation]: - """Extract URL citations from MessageDeltaChunk.""" - url_citations: list[Annotation] = [] - - # Process each content item in the delta to find citations - for content in message_delta_chunk.delta.content: - if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations: - for annotation in content.text.annotations: - if isinstance(annotation, MessageDeltaTextUrlCitationAnnotation): - # Create annotated regions only if both start and end indices are available - annotated_regions = [] - if annotation.start_index and annotation.end_index: - annotated_regions = [ - TextSpanRegion( - type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, - ) - ] - - # Extract real URL from Azure AI Search tool calls - real_url = self._get_real_url_from_citation_reference( - annotation.url_citation.url, azure_search_tool_calls - ) - - # Create Annotation with real URL - citation = Annotation( - type="citation", - title=annotation.url_citation.title, # type: ignore[typeddict-item] - url=real_url, - snippet=None, # type: ignore[typeddict-item] - annotated_regions=annotated_regions, - raw_representation=annotation, - ) - url_citations.append(citation) - - return url_citations - - def _extract_file_path_contents(self, message_delta_chunk: MessageDeltaChunk) -> list[Content]: - """Extract file references from MessageDeltaChunk annotations. - - Code interpreter generates files that are referenced via file path or file citation - annotations in the message content. This method extracts those file IDs and returns - them as HostedFileContent objects. - - Handles two annotation types: - - MessageDeltaTextFilePathAnnotation: Contains file_path.file_id - - MessageDeltaTextFileCitationAnnotation: Contains file_citation.file_id - - Args: - message_delta_chunk: The message delta chunk to process - - Returns: - List of HostedFileContent objects for any files referenced in annotations - """ - file_contents: list[Content] = [] - - for content in message_delta_chunk.delta.content: - if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations: - for annotation in content.text.annotations: - if isinstance(annotation, MessageDeltaTextFilePathAnnotation): - # Extract file_id from the file_path annotation - file_path = getattr(annotation, "file_path", None) - if file_path is not None: - file_id = getattr(file_path, "file_id", None) - if file_id: - file_contents.append(Content.from_hosted_file(file_id=file_id)) - elif isinstance(annotation, MessageDeltaTextFileCitationAnnotation): - # Extract file_id from the file_citation annotation - file_citation = getattr(annotation, "file_citation", None) - if file_citation is not None: - file_id = getattr(file_citation, "file_id", None) - if file_id: - file_contents.append(Content.from_hosted_file(file_id=file_id)) - - return file_contents - - def _get_real_url_from_citation_reference( - self, citation_url: str, azure_search_tool_calls: list[dict[str, Any]] - ) -> str: - """Extract real URL from Azure AI Search tool calls based on citation reference. - - Args: - citation_url: Citation reference URL (e.g., "doc_0", "#doc_1", or full URL with doc_N) - azure_search_tool_calls: List of captured Azure AI Search tool calls - - Returns: - Real document URL if found, otherwise original citation_url - """ - # Extract document index from citation URL (e.g., "doc_0" -> 0) - match = re.search(r"doc_(\d+)", citation_url) - if not match: - return citation_url - - doc_index = int(match.group(1)) - - # Get Azure AI Search tool calls - if not azure_search_tool_calls: - return citation_url - - try: - # Extract URLs from the most recent Azure AI Search tool call - tool_call = azure_search_tool_calls[-1] # Most recent call - output_str = tool_call["azure_ai_search"]["output"] - - # Parse the tool call output to get URLs - output_data = ast.literal_eval(output_str) - all_urls = output_data["metadata"]["get_urls"] - - # Return the URL at the specified index, if it exists - if 0 <= doc_index < len(all_urls): - return str(all_urls[doc_index]) - - except (KeyError, IndexError, TypeError, ValueError, SyntaxError) as ex: - logger.debug(f"Failed to extract real URL for {citation_url}: {ex}") - - return citation_url - - async def _process_stream( - self, stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], thread_id: str - ) -> AsyncIterable[ChatResponseUpdate]: - """Process events from the stream iterator and yield ChatResponseUpdate objects.""" - response_id: str | None = None - # Track Azure Search tool calls for this stream only - azure_search_tool_calls: list[dict[str, Any]] = [] - response_stream = await stream.__aenter__() if isinstance(stream, AsyncAgentRunStream) else stream # type: ignore[no-untyped-call] - try: - async for event_type, event_data, _ in response_stream: - match event_data: - case MessageDeltaChunk(): - # only one event_type: AgentStreamEvent.THREAD_MESSAGE_DELTA - role: Role = "user" if event_data.delta.role == "user" else "assistant" # type: ignore[assignment] - - # Extract URL citations from the delta chunk - url_citations = self._extract_url_citations(event_data, azure_search_tool_calls) - - # Extract file path contents from code interpreter outputs - file_contents = self._extract_file_path_contents(event_data) - - # Create contents with citations if any exist - citation_content: list[Content] = [] - if event_data.text or url_citations: - text_content_obj = Content.from_text(text=event_data.text or "") - if url_citations: - text_content_obj.annotations = url_citations - citation_content.append(text_content_obj) - - # Add file contents from file path annotations - citation_content.extend(file_contents) - - yield ChatResponseUpdate( - role=role, - contents=citation_content if citation_content else None, - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - ) - case ThreadRun(): - # possible event_types: - # AgentStreamEvent.THREAD_RUN_CREATED - # AgentStreamEvent.THREAD_RUN_QUEUED - # AgentStreamEvent.THREAD_RUN_INCOMPLETE - # AgentStreamEvent.THREAD_RUN_IN_PROGRESS - # AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION - # AgentStreamEvent.THREAD_RUN_COMPLETED - # AgentStreamEvent.THREAD_RUN_FAILED - # AgentStreamEvent.THREAD_RUN_CANCELLING - # AgentStreamEvent.THREAD_RUN_CANCELLED - # AgentStreamEvent.THREAD_RUN_EXPIRED - match event_type: - case AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION: - if event_data.required_action and event_data.required_action.type in [ - "submit_tool_outputs", - "submit_tool_approval", - ]: - function_call_contents = self._parse_function_calls_from_azure_ai( - event_data, response_id - ) - if function_call_contents: - yield ChatResponseUpdate( - role="assistant", - contents=function_call_contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - ) - case AgentStreamEvent.THREAD_RUN_FAILED: - raise ChatClientException(event_data.last_error.message) - case _: - yield ChatResponseUpdate( - contents=[], - conversation_id=event_data.thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - role="assistant", - model_id=event_data.model, - ) - - case RunStep(): - # possible event_types: - # AgentStreamEvent.THREAD_RUN_STEP_CREATED, - # AgentStreamEvent.THREAD_RUN_STEP_IN_PROGRESS, - # AgentStreamEvent.THREAD_RUN_STEP_COMPLETED, - # AgentStreamEvent.THREAD_RUN_STEP_FAILED, - # AgentStreamEvent.THREAD_RUN_STEP_CANCELLED, - # AgentStreamEvent.THREAD_RUN_STEP_EXPIRED, - match event_type: - case AgentStreamEvent.THREAD_RUN_STEP_CREATED: - response_id = event_data.run_id - case AgentStreamEvent.THREAD_RUN_COMPLETED | AgentStreamEvent.THREAD_RUN_STEP_COMPLETED: - # Capture Azure AI Search tool calls when steps complete - if event_type == AgentStreamEvent.THREAD_RUN_STEP_COMPLETED: - self._capture_azure_search_tool_calls(event_data, azure_search_tool_calls) - - if event_data.usage: - usage_content = Content.from_usage( - UsageDetails( - input_token_count=event_data.usage.prompt_tokens, - output_token_count=event_data.usage.completion_tokens, - total_token_count=event_data.usage.total_tokens, - ) - ) - yield ChatResponseUpdate( - role="assistant", - contents=[usage_content], - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - ) - case _: - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - role="assistant", - ) - case RunStepDeltaChunk(): # type: ignore - step_details = event_data.delta.step_details - if step_details is not None and step_details.type == "tool_calls": - tool_calls = cast(list[RunStepDeltaToolCall], step_details.tool_calls) # type: ignore - for tool_call in tool_calls: - if tool_call.type == "code_interpreter" and tool_call.code_interpreter is not None: # type: ignore[attr-defined, reportUnknownMemberType] - code_contents: list[Content] = [] - if tool_call.code_interpreter.input is not None: # type: ignore[attr-defined, reportUnknownMemberType] - logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}") # type: ignore[attr-defined, reportUnknownMemberType] - if tool_call.code_interpreter.outputs is not None: # type: ignore[attr-defined, reportUnknownMemberType] - for output in tool_call.code_interpreter.outputs: # type: ignore[attr-defined, reportUnknownMemberType] - if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs: - code_contents.append(Content.from_text(text=output.logs)) - if ( - isinstance(output, RunStepDeltaCodeInterpreterImageOutput) - and output.image is not None - and output.image.file_id is not None - ): - code_contents.append( - Content.from_hosted_file(file_id=output.image.file_id) - ) - yield ChatResponseUpdate( - role="assistant", - contents=code_contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=tool_call.code_interpreter, # type: ignore[attr-defined, reportUnknownMemberType] - response_id=response_id, - ) - case _: # ThreadMessage or string - # possible event_types for ThreadMessage: - # AgentStreamEvent.THREAD_MESSAGE_CREATED - # AgentStreamEvent.THREAD_MESSAGE_IN_PROGRESS - # AgentStreamEvent.THREAD_MESSAGE_COMPLETED - # AgentStreamEvent.THREAD_MESSAGE_INCOMPLETE - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, # type: ignore - response_id=response_id, - role="assistant", - ) - except Exception as ex: - logger.error(f"Error processing stream: {ex}") - raise - finally: - if isinstance(stream, AsyncAgentRunStream): - await stream.__aexit__(None, None, None) # type: ignore[no-untyped-call] - - def _capture_azure_search_tool_calls( - self, step_data: RunStep, azure_search_tool_calls: list[dict[str, Any]] - ) -> None: - """Capture Azure AI Search tool call data from completed steps.""" - try: - step_details = getattr(step_data, "step_details", None) - tool_calls = getattr(step_details, "tool_calls", None) if step_details is not None else None - if isinstance(tool_calls, list): - for tool_call in cast(list[object], tool_calls): - if getattr(tool_call, "type", None) == "azure_ai_search": - # Store the complete tool call as a dictionary - tool_call_dict = { - "id": getattr(tool_call, "id", None), - "type": getattr(tool_call, "type", None), - "azure_ai_search": getattr(tool_call, "azure_ai_search", None), - } - azure_search_tool_calls.append(tool_call_dict) - logger.debug(f"Captured Azure AI Search tool call: {tool_call_dict['id']}") - except Exception as ex: - logger.debug(f"Failed to capture Azure AI Search tool call: {ex}") - - def _parse_function_calls_from_azure_ai(self, event_data: ThreadRun, response_id: str | None) -> list[Content]: - """Parse function call contents from an Azure AI tool action event.""" - if isinstance(event_data, ThreadRun) and event_data.required_action is not None: - if isinstance(event_data.required_action, SubmitToolOutputsAction): - return [ - Content.from_function_call( - call_id=f'["{response_id}", "{tool.id}"]', - name=tool.function.name, - arguments=tool.function.arguments, - ) - for tool in event_data.required_action.submit_tool_outputs.tool_calls - if isinstance(tool, RequiredFunctionToolCall) - ] - if isinstance(event_data.required_action, SubmitToolApprovalAction): - return [ - Content.from_function_approval_request( - id=f'["{response_id}", "{tool.id}"]', - function_call=Content.from_function_call( - call_id=f'["{response_id}", "{tool.id}"]', - name=tool.name, - arguments=tool.arguments, - raw_representation=tool, - ), - raw_representation=tool, - ) - for tool in event_data.required_action.submit_tool_approval.tool_calls - if isinstance(tool, RequiredMcpToolCall) - ] - return [] - - async def _close_client_if_needed(self) -> None: - """Close agents_client session if we created it.""" - if self._should_close_client: - await self.agents_client.close() - - async def _cleanup_agent_if_needed(self) -> None: - """Clean up the agent if we created it.""" - if self._agent_created and self.should_cleanup_agent and self.agent_id is not None: - await self.agents_client.delete_agent(self.agent_id) - self.agent_id = None - self._agent_created = False - - async def _load_agent_definition_if_needed(self) -> AzureAgent | None: - """Load and cache agent details if not already loaded.""" - if self._agent_definition is None and self.agent_id is not None: - self._agent_definition = await self.agents_client.get_agent(self.agent_id) - return self._agent_definition - - async def _prepare_options( - self, - messages: Sequence[Message], - options: Mapping[str, Any], - **kwargs: Any, - ) -> tuple[dict[str, Any], list[Content] | None]: - agent_definition = await self._load_agent_definition_if_needed() - - # Build run_options from options dict, excluding specific keys - exclude_keys = { - "type", - "instructions", # handled via messages - "tools", # handled separately - "tool_choice", # handled separately - "response_format", # handled separately - "additional_properties", # handled separately - "frequency_penalty", # not supported - "presence_penalty", # not supported - "user", # not supported - "stop", # not supported - "logit_bias", # not supported - "seed", # not supported - "store", # not supported - } - run_options: dict[str, Any] = {k: v for k, v in options.items() if k not in exclude_keys and v is not None} - - # Translation between ChatOptions and Azure AI Agents API - translations = { - "model_id": "model", - "allow_multiple_tool_calls": "parallel_tool_calls", - "max_tokens": "max_completion_tokens", - } - for old_key, new_key in translations.items(): - if old_key in run_options and old_key != new_key: - run_options[new_key] = run_options.pop(old_key) - - # model id fallback - if not run_options.get("model"): - run_options["model"] = self.model_id - - # tools and tool_choice - if tool_definitions := await self._prepare_tool_definitions_and_resources( - options, agent_definition, run_options - ): - run_options["tools"] = tool_definitions - - if tool_choice := self._prepare_tool_choice_mode(options): - run_options["tool_choice"] = tool_choice - - # response format - response_format = options.get("response_format") - if response_format is not None: - if isinstance(response_format, type) and issubclass(response_format, BaseModel): - # Pydantic model - convert to Azure format - run_options["response_format"] = ResponseFormatJsonSchemaType( - json_schema=ResponseFormatJsonSchema( - name=response_format.__name__, - schema=response_format.model_json_schema(), - ) - ) - elif isinstance(response_format, Mapping): - # Runtime JSON schema dict - pass through as-is - run_options["response_format"] = response_format - else: - raise ChatClientInvalidRequestException( - "response_format must be a Pydantic BaseModel class or a dict with runtime JSON schema." - ) - - # messages - additional_messages, instructions, required_action_results = self._prepare_messages(messages) - if additional_messages: - run_options["additional_messages"] = additional_messages - - # Add instructions from options (agent's instructions set via as_agent()) - if options_instructions := options.get("instructions"): - instructions.append(options_instructions) - - # Add instruction from existing agent at the beginning - if ( - agent_definition is not None - and agent_definition.instructions - and agent_definition.instructions not in instructions - ): - instructions.insert(0, agent_definition.instructions) - - if instructions: - run_options["instructions"] = "\n".join(instructions) - - # thread_id resolution (conversation_id takes precedence, then kwargs, then instance default) - run_options["thread_id"] = options.get("conversation_id") or kwargs.get("conversation_id") or self.thread_id - - return run_options, required_action_results - - def _prepare_tool_choice_mode( - self, options: Mapping[str, Any] - ) -> AgentsToolChoiceOptionMode | AgentsNamedToolChoice | None: - """Prepare the tool choice mode for Azure AI Agents API.""" - tool_choice = cast(str | dict[str, str] | None, options.get("tool_choice")) - if tool_choice is None: - return None - if isinstance(tool_choice, str) and tool_choice in {"none", "auto"}: - return AgentsToolChoiceOptionMode(tool_choice) - if isinstance(tool_choice, dict): - mode = tool_choice.get("mode") - req_fn = tool_choice.get("required_function_name") - if mode == "required" and req_fn is not None: - return AgentsNamedToolChoice( - type=AgentsNamedToolChoiceType.FUNCTION, - function=FunctionName(name=req_fn), - ) - return None - - async def _prepare_tool_definitions_and_resources( - self, - options: Mapping[str, Any], - agent_definition: AzureAgent | None, - run_options: dict[str, Any], - ) -> list[ToolDefinition | dict[str, Any]]: - """Prepare tool definitions and resources for the run options.""" - tool_definitions: list[ToolDefinition | dict[str, Any]] = [] - - # Add tools from existing agent (exclude function tools - passed via options.get("tools")) - if agent_definition is not None: - agent_tools = [tool for tool in agent_definition.tools if not isinstance(tool, FunctionToolDefinition)] - if agent_tools: - tool_definitions.extend(agent_tools) - if agent_definition.tool_resources: - run_options["tool_resources"] = agent_definition.tool_resources - - # Add run tools - always include tools if provided, regardless of tool_choice - # tool_choice="none" means the model won't call tools, but tools should still be available - tools = options.get("tools") - if tools: - tool_definitions.extend(to_azure_ai_agent_tools(tools, run_options)) - - # Handle MCP tool resources - mcp_resources = self._prepare_mcp_resources(tools) - if mcp_resources: - if "tool_resources" not in run_options: - run_options["tool_resources"] = {} - run_options["tool_resources"]["mcp"] = mcp_resources - - return tool_definitions - - def _prepare_mcp_resources(self, tools: Sequence[Any]) -> list[dict[str, Any]]: - """Prepare MCP tool resources for approval mode configuration. - - Extracts MCP resources from McpTool instances including server_label, - require_approval, and headers. - """ - mcp_resources: list[dict[str, Any]] = [] - for tool in tools: - if isinstance(tool, McpTool): - # Use the resources property which includes all config (approval, headers) - tool_resources = tool.resources - if tool_resources and tool_resources.mcp: - for mcp_resource in tool_resources.mcp: - resource_dict: dict[str, Any] = {"server_label": mcp_resource.server_label} - if mcp_resource.require_approval: - resource_dict["require_approval"] = mcp_resource.require_approval - if mcp_resource.headers: - resource_dict["headers"] = mcp_resource.headers - mcp_resources.append(resource_dict) - return mcp_resources - - def _prepare_messages( - self, messages: Sequence[Message] - ) -> tuple[ - list[ThreadMessageOptions] | None, - list[str], - list[Content] | None, - ]: - """Prepare messages for Azure AI Agents API. - - System/developer messages are turned into instructions, since there is no such message roles in Azure AI. - All other messages are added 1:1, treating assistant messages as agent messages - and everything else as user messages. - - Returns: - Tuple of (additional_messages, instructions, required_action_results) - """ - instructions: list[str] = [] - required_action_results: list[Content] | None = None - additional_messages: list[ThreadMessageOptions] | None = None - - for chat_message in messages: - if chat_message.role in ["system", "developer"]: - for text_content in [content for content in chat_message.contents if content.type == "text"]: - instructions.append(text_content.text) # type: ignore[arg-type] - continue - - message_contents: list[MessageInputContentBlock] = [] - - for content in chat_message.contents: - match content.type: - case "text": - message_contents.append(MessageInputTextBlock(text=content.text)) # type: ignore[arg-type] - case "data" | "uri": - if content.has_top_level_media_type("image"): - message_contents.append( - MessageInputImageUrlBlock(image_url=MessageImageUrlParam(url=content.uri)) # type: ignore[arg-type] - ) - # Only images are supported. Other media types are ignored. - case "function_result" | "function_approval_response": - if required_action_results is None: - required_action_results = [] - required_action_results.append(content) - case _: - if isinstance(content.raw_representation, MessageInputContentBlock): - message_contents.append(content.raw_representation) - - if message_contents: - if additional_messages is None: - additional_messages = [] - additional_messages.append( - ThreadMessageOptions( - role=MessageRole.AGENT if chat_message.role == "assistant" else MessageRole.USER, - content=message_contents, - ) - ) - - return additional_messages, instructions, required_action_results - - async def _prepare_tools_for_azure_ai( - self, tools: Sequence[Any], run_options: dict[str, Any] | None = None - ) -> list[Any]: - """Prepare tool definitions for the Azure AI Agents API. - - Converts FunctionTool to JSON schema format. SDK Tool wrappers with .definitions - are unpacked. All other tools (ToolDefinition, dict, etc.) pass through unchanged. - - Args: - tools: Sequence of tools to prepare. - run_options: Optional run options dict that may be updated with tool_resources. - - Returns: - List of tool definitions ready for the Azure AI API. - """ - tool_definitions: list[Any] = [] - for tool in tools: - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) - elif hasattr(tool, "definitions") and not isinstance(tool, MutableMapping): - # SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.) - tool_definitions.extend(tool.definitions) - # Handle tool resources (MCP resources handled separately by _prepare_mcp_resources) - resources = getattr(tool, "resources", None) - if run_options is not None and resources and isinstance(resources, Mapping) and "mcp" not in resources: - run_options.setdefault("tool_resources", {}) - run_options["tool_resources"].update(tool.resources) - else: - # Pass through ToolDefinition, dict, and other types unchanged - tool_definitions.append(tool) - return tool_definitions - - def _prepare_tool_outputs_for_azure_ai( - self, - required_action_results: list[Content] | None, - ) -> tuple[str | None, list[ToolOutput] | None, list[ToolApproval] | None]: - """Prepare function results and approvals for submission to the Azure AI API.""" - run_id: str | None = None - tool_outputs: list[ToolOutput] | None = None - tool_approvals: list[ToolApproval] | None = None - - if required_action_results: - for content in required_action_results: - # When creating the FunctionCallContent/ApprovalRequestContent, - # we created it with a CallId == [runId, callId]. - # We need to extract the run ID and ensure that the Output/Approval we send back to Azure - # is only the call ID. - run_and_call_ids: list[str] = ( - json.loads(content.call_id) if content.type == "function_result" else json.loads(content.id) # type: ignore[arg-type] - ) - - if ( - not run_and_call_ids - or len(run_and_call_ids) != 2 - or not run_and_call_ids[0] - or not run_and_call_ids[1] - or (run_id is not None and run_id != run_and_call_ids[0]) - ): - continue - - run_id = run_and_call_ids[0] - call_id = run_and_call_ids[1] - - if content.type == "function_result": - if content.items: - text_parts = [item.text or "" for item in content.items if item.type == "text"] - rich_items = [item for item in content.items if item.type in ("data", "uri")] - if rich_items: - logger.warning( - "Azure AI Agents does not support rich content (images, audio) in tool results. " - "Rich content items will be omitted." - ) - output_text = "\n".join(text_parts) if text_parts else "" - else: - output_text = content.result if content.result is not None else "" - if tool_outputs is None: - tool_outputs = [] - tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output_text)) - elif content.type == "function_approval_response": - if tool_approvals is None: - tool_approvals = [] - tool_approvals.append(ToolApproval(tool_call_id=call_id, approve=content.approved)) # type: ignore[arg-type] - - return run_id, tool_outputs, tool_approvals - - def _update_agent_name_and_description(self, agent_name: str | None, description: str | None) -> None: - """Update the agent name in the chat client. - - Args: - agent_name: The new name for the agent. - description: The new description for the agent. - """ - # This is a no-op in the base class, but can be overridden by subclasses - # to update the agent name in the client. - if agent_name and not self.agent_name: - self.agent_name = agent_name - if description and not self.agent_description: - self.agent_description = description - - def service_url(self) -> str: - """Get the service URL for the chat client. - - Returns: - The service URL for the chat client, or None if not set. - """ - return self.agents_client._config.endpoint # type: ignore - - @override - def as_agent( - self, - *, - id: str | None = None, - name: str | None = None, - description: str | None = None, - instructions: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: AzureAIAgentOptionsT | Mapping[str, Any] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - **kwargs: Any, - ) -> Agent[AzureAIAgentOptionsT]: - """Convert this chat client to a Agent. - - This method creates a Agent instance with this client pre-configured. - It does NOT create an agent on the Azure AI service - the actual agent - will be created on the server during the first invocation (run). - - For creating and managing persistent agents on the server, use - :class:`~agent_framework_azure_ai.AzureAIAgentsProvider` instead. - - Keyword Args: - id: The unique identifier for the agent. Will be created automatically if not provided. - name: The name of the agent. Defaults to the client's ``agent_name`` when None. - description: A brief description of the agent's purpose. Defaults to the client's - ``agent_description`` when None. - instructions: Optional instructions for the agent. - tools: The tools to use for the request. - default_options: A TypedDict containing chat options. - context_providers: Context providers to include during agent invocation. - middleware: List of middleware to intercept agent and function invocations. - kwargs: Any additional keyword arguments. - - Returns: - A Agent instance configured with this chat client. - """ - return super().as_agent( - id=id, - name=self.agent_name if name is None else name, - description=self.agent_description if description is None else description, - instructions=instructions, - tools=tools, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - **kwargs, - ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py deleted file mode 100644 index f4ceb92b5b..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ /dev/null @@ -1,1329 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import json -import logging -import re -import sys -from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence -from contextlib import suppress -from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - Annotation, - BaseContextProvider, - ChatAndFunctionMiddlewareTypes, - ChatMiddlewareLayer, - ChatResponse, - ChatResponseUpdate, - Content, - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - Message, - MiddlewareTypes, - ResponseStream, - TextSpanRegion, -) -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from agent_framework.observability import ChatTelemetryLayer -from agent_framework.openai import OpenAIResponsesOptions -from agent_framework_openai._chat_client import RawOpenAIChatClient -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import ( - ApproximateLocation, - AutoCodeInterpreterToolParam, - CodeInterpreterTool, - ImageGenTool, - MCPTool, - PromptAgentDefinition, - PromptAgentDefinitionTextOptions, - RaiConfig, - Reasoning, - WebSearchPreviewTool, -) -from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool -from azure.core.exceptions import ResourceNotFoundError - -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, create_text_format_config, resolve_file_ids - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore[import] # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - -logger = logging.getLogger("agent_framework.azure") - - -class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False): # type: ignore[misc, call-arg] - """Azure AI Project Agent options.""" - - rai_config: RaiConfig - """Configuration for Responsible AI (RAI) content filtering and safety features.""" - - reasoning: Reasoning # type: ignore[misc] - """Configuration for enabling reasoning capabilities (requires azure.ai.projects.models.Reasoning).""" - - -AzureAIClientOptionsT = TypeVar( - "AzureAIClientOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIProjectAgentOptions", - covariant=True, -) - -_DOC_INDEX_PATTERN = re.compile(r"doc_(\d+)") - - -@deprecated( - "RawAzureAIClient is deprecated. " - "Use RawFoundryAgentChatClient for low-level Foundry agent client customization, " - "or FoundryAgent for the recommended production API." -) -class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]): - """Deprecated raw Azure AI client without middleware, telemetry, or function invocation layers. - - Warning: - **This class should not normally be used directly.** It does not include middleware, - telemetry, or function invocation support that you most likely need. If you do use it, - you should consider which additional layers to apply. There is a defined ordering that - you should follow: - - 1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware - 2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry - 3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry - - Use ``RawFoundryAgentChatClient`` for low-level Foundry agent customization, or - ``FoundryAgent`` for the recommended production API. - """ - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] - - def __init__( - self, - *, - project_client: AIProjectClient | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - agent_description: str | None = None, - conversation_id: str | None = None, - project_endpoint: str | None = None, - model_deployment_name: str | None = None, - credential: AzureCredentialTypes | None = None, - use_latest_version: bool | None = None, - allow_preview: bool | None = None, - additional_properties: dict[str, Any] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize a bare Azure AI client. - - This is the core implementation without middleware, telemetry, or function invocation layers. - For most use cases, prefer :class:`AzureAIClient` which includes all standard layers. - - Keyword Args: - project_client: An existing AIProjectClient to use. If not provided, one will be created. - agent_name: The name to use when creating new agents or using existing agents. - agent_version: The version of the agent to use. - agent_description: The description to use when creating new agents. - conversation_id: Default conversation ID to use for conversations. Can be overridden by - conversation_id property when making a request. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a project_client is passed. - model_deployment_name: The model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - use_latest_version: Boolean flag that indicates whether to use latest agent version - if it exists in the service. - allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``. - additional_properties: Additional properties stored on the client instance. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - from azure.identity.aio import DefaultAzureCredential - - # Using environment variables - # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 - credential = DefaultAzureCredential() - client = AzureAIClient(credential=credential) - - # Or passing parameters directly - client = AzureAIClient( - project_endpoint="https://your-project.cognitiveservices.azure.com", - model_deployment_name="gpt-4", - credential=credential, - ) - - # Or loading from a .env file - client = AzureAIClient(credential=credential, env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework import ChatOptions - - - class MyOptions(ChatOptions, total=False): - my_custom_option: str - - - client: AzureAIClient[MyOptions] = AzureAIClient(credential=credential) - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - # If no project_client is provided, create one - should_close_client = False - if project_client is None: - resolved_endpoint = azure_ai_settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " - "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - - # Use provided credential - if not credential: - raise ValueError("Azure credential is required when project_client is not provided.") - project_client_kwargs: dict[str, Any] = { - "endpoint": resolved_endpoint, - "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, - } - if allow_preview is not None: - project_client_kwargs["allow_preview"] = allow_preview - project_client = AIProjectClient(**project_client_kwargs) - should_close_client = True - - # Initialize parent with OpenAI client from project - super().__init__( # type: ignore - async_client=project_client.get_openai_client(), - model=azure_ai_settings.get("model"), # type: ignore[arg-type] - additional_properties=additional_properties, - ) - - # Initialize instance variables - self.agent_name = agent_name - self.agent_version = agent_version - self.agent_description = agent_description - self.use_latest_version = use_latest_version - self.project_client = project_client - self.credential = credential - self.model_id = azure_ai_settings.get("model_deployment_name") - self.conversation_id = conversation_id - - # Track whether the application endpoint is used - self._is_application_endpoint = "/applications/" in project_client._config.endpoint # type: ignore - # Track whether we should close client connection - self._should_close_client = should_close_client - # Track creation-time agent configuration for runtime mismatch warnings. - self.warn_runtime_tools_and_structure_changed = False - self._created_agent_tool_names: set[str] = set() - self._created_agent_structured_output_signature: str | None = None - - async def configure_azure_monitor( - self, - enable_sensitive_data: bool = False, - **kwargs: Any, - ) -> None: - """Setup observability with Azure Monitor (Azure AI Foundry integration). - - This method configures Azure Monitor for telemetry collection using the - connection string from the Azure AI project client. - - Args: - enable_sensitive_data: Enable sensitive data logging (prompts, responses). - Should only be enabled in development/test environments. Default is False. - **kwargs: Additional arguments passed to configure_azure_monitor(). - Common options include: - - enable_live_metrics (bool): Enable Azure Monitor Live Metrics - - credential (TokenCredential): Azure credential for Entra ID auth - - resource (Resource): Custom OpenTelemetry resource - See https://learn.microsoft.com/python/api/azure-monitor-opentelemetry/azure.monitor.opentelemetry.configure_azure_monitor - for full list of options. - - Raises: - ImportError: If azure-monitor-opentelemetry-exporter is not installed. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - from azure.ai.projects.aio import AIProjectClient - from azure.identity.aio import DefaultAzureCredential - - async with ( - DefaultAzureCredential() as credential, - AIProjectClient( - endpoint="https://your-project.api.azureml.ms", credential=credential - ) as project_client, - AzureAIClient(project_client=project_client) as client, - ): - # Setup observability with defaults - await client.configure_azure_monitor() - - # With live metrics enabled - await client.configure_azure_monitor(enable_live_metrics=True) - - # With sensitive data logging (dev/test only) - await client.configure_azure_monitor(enable_sensitive_data=True) - - Note: - This method retrieves the Application Insights connection string from the - Azure AI project client automatically. You must have Application Insights - configured in your Azure AI project for this to work. - """ - # Get connection string from project client - try: - conn_string = await self.project_client.telemetry.get_application_insights_connection_string() - except ResourceNotFoundError: - logger.warning( - "No Application Insights connection string found for the Azure AI Project. " - "Please ensure Application Insights is configured in your Azure AI project, " - "or call configure_otel_providers() manually with custom exporters." - ) - return - - # Import Azure Monitor with proper error handling - try: - from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] - except ImportError as exc: - raise ImportError( - "azure-monitor-opentelemetry is required for Azure Monitor integration. " - "Install it with: pip install azure-monitor-opentelemetry" - ) from exc - - from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation - - # Create resource if not provided in kwargs - if "resource" not in kwargs: - kwargs["resource"] = create_resource() - - # Configure Azure Monitor with connection string and kwargs - configure_azure_monitor( - connection_string=conn_string, - views=create_metric_views(), - **kwargs, - ) - - # Complete setup with core observability - enable_instrumentation(enable_sensitive_data=enable_sensitive_data) - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the project_client.""" - await self._close_client_if_needed() - - async def _get_agent_reference_or_create( - self, - run_options: dict[str, Any], - messages_instructions: str | None, - chat_options: Mapping[str, Any] | None = None, - ) -> dict[str, str]: - """Determine which agent to use and create if needed. - - Args: - run_options: The prepared options for the API call. - messages_instructions: Instructions extracted from messages. - chat_options: The chat options containing response_format and other settings. - - Returns: - dict[str, str]: The agent reference to use. - """ - # Agent name must be explicitly provided by the user. - if self.agent_name is None: - raise ValueError( - "Agent name is required. Provide 'agent_name' when initializing AzureAIClient " - "or 'name' when initializing Agent." - ) - # If the agent exists and we do not want to track agent configuration, return early - if self.agent_version is not None and not self.warn_runtime_tools_and_structure_changed: - return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} - - # If no agent_version is provided, either use latest version or create a new agent: - if self.agent_version is None: - # Try to use latest version if requested and agent exists - if self.use_latest_version: - with suppress(ResourceNotFoundError): - existing_agent = await self.project_client.agents.get(self.agent_name) - self.agent_version = existing_agent.versions.latest.version - return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} - - if "model" not in run_options or not run_options["model"]: - raise ValueError( - "Model deployment name is required for agent creation, " - "can also be passed to the get_response methods." - ) - - args: dict[str, Any] = {"model": run_options["model"]} - - if "tools" in run_options: - args["tools"] = run_options["tools"] - if "temperature" in run_options: - args["temperature"] = run_options["temperature"] - if "top_p" in run_options: - args["top_p"] = run_options["top_p"] - if "reasoning" in run_options: - args["reasoning"] = run_options["reasoning"] - if "rai_config" in run_options: - args["rai_config"] = run_options["rai_config"] - - # response_format is accessed from chat_options or additional_properties - # since the base class excludes it from run_options - if chat_options and (response_format := chat_options.get("response_format")): - args["text"] = PromptAgentDefinitionTextOptions(format=create_text_format_config(response_format)) - - # Combine instructions from messages and options - # instructions is accessed from chat_options since the base class excludes it from run_options - combined_instructions = [ - instructions - for instructions in [messages_instructions, chat_options.get("instructions") if chat_options else None] - if instructions - ] - if combined_instructions: - args["instructions"] = "".join(combined_instructions) - - create_version_kwargs: dict[str, Any] = { - "agent_name": self.agent_name, - "definition": PromptAgentDefinition(**args), - "description": self.agent_description, - } - - created_agent = await self.project_client.agents.create_version(**create_version_kwargs) - - self.agent_version = created_agent.version - self.warn_runtime_tools_and_structure_changed = True - self._created_agent_tool_names = self._extract_tool_names(run_options.get("tools")) - self._created_agent_structured_output_signature = self._get_structured_output_signature(chat_options) - return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} - - async def _close_client_if_needed(self) -> None: - """Close project_client session if we created it.""" - if self._should_close_client: - await self.project_client.close() - - def _extract_tool_names(self, tools: Any) -> set[str]: - """Extract comparable tool names from runtime tool payloads.""" - if not isinstance(tools, Sequence) or isinstance(tools, str | bytes): - return set() - tool_names: set[str] = set() - for tool_item in cast(Sequence[object], tools): - tool_names.add(self._get_tool_name(tool_item)) - return tool_names - - def _get_tool_name(self, tool: Any) -> str: - """Get a stable name for a tool for runtime comparison.""" - if isinstance(tool, FunctionTool): - return tool.name - - if isinstance(tool, Mapping): - tool_type = tool.get("type") # type: ignore[reportUnknownMemberType] - if tool_type == "function": - function_data = tool.get("function") # type: ignore[reportUnknownMemberType] - if isinstance(function_data, Mapping) and (function_name := function_data.get("name")): # type: ignore[assignment] - return function_name # type: ignore[no-any-return] - if tool_name := tool.get("name"): # type: ignore[reportUnknownMemberType] - return tool_name # type: ignore[no-any-return] - if server_label := tool.get("server_label"): # type: ignore[reportUnknownMemberType] - return f"mcp:{server_label}" - if tool_type: - return tool_type # type: ignore[no-any-return] - raise ValueError("Dict based tool definitions must include a 'name' property for runtime comparison.") - - if name_value := getattr(tool, "name", None): - return name_value # type: ignore[no-any-return] - if server_label_value := getattr(tool, "server_label", None): - return f"mcp:{server_label_value}" - if tool_type_value := getattr(tool, "type", None): - return tool_type_value # type: ignore[no-any-return] - return type(tool).__name__ - - def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | None) -> str | None: - """Build a stable signature for structured_output/response_format values.""" - if not chat_options: - return None - response_format = chat_options.get("response_format") - if response_format is None: - return None - if isinstance(response_format, type): - return f"{response_format.__module__}.{response_format.__qualname__}" - if isinstance(response_format, Mapping): - return json.dumps(response_format, sort_keys=True, default=str) - return str(response_format) - - def _remove_agent_level_run_options( - self, - run_options: dict[str, Any], - chat_options: Mapping[str, Any] | None = None, - ) -> None: - """Remove request-level options that Azure AI only supports at agent creation time.""" - runtime_tools = run_options.get("tools") - runtime_structured_output = self._get_structured_output_signature(chat_options) - - if runtime_tools is not None or runtime_structured_output is not None: - tools_changed = runtime_tools is not None - structured_output_changed = runtime_structured_output is not None - - if self.warn_runtime_tools_and_structure_changed: - if runtime_tools is not None: - tools_changed = self._extract_tool_names(runtime_tools) != self._created_agent_tool_names - if runtime_structured_output is not None: - structured_output_changed = ( - runtime_structured_output != self._created_agent_structured_output_signature - ) - - if tools_changed or structured_output_changed: - logger.warning( - "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " - "Use AzureOpenAIResponsesClient instead." - ) - - agent_level_option_to_run_keys = { - "model_id": ("model",), - "tools": ("tools",), - "response_format": ("response_format", "text", "text_format"), - "rai_config": ("rai_config",), - "temperature": ("temperature",), - "top_p": ("top_p",), - "reasoning": ("reasoning",), - "allow_preview": ("allow_preview",), - } - - for run_keys in agent_level_option_to_run_keys.values(): - for run_key in run_keys: - run_options.pop(run_key, None) - - @override - async def _prepare_options( - self, - messages: Sequence[Message], - options: Mapping[str, Any], - **kwargs: Any, - ) -> dict[str, Any]: - """Take ChatOptions and create the specific options for Azure AI.""" - prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages) - run_options = await super()._prepare_options(prepared_messages, options, **kwargs) - - # WORKAROUND: Azure AI Projects 'create responses' API has schema divergence from OpenAI's - # Responses API. Azure requires 'type' at item level and 'annotations' in content items. - # See: https://github.com/Azure/azure-sdk-for-python/issues/44493 - # See: https://github.com/microsoft/agent-framework/issues/2926 - # TODO(agent-framework#2926): Remove this workaround when Azure SDK aligns with OpenAI schema. - if "input" in run_options and isinstance(run_options["input"], list): - run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) - - if not self._is_application_endpoint: - # Application-scoped response APIs do not support "agent_reference" property. - agent_reference = await self._get_agent_reference_or_create(run_options, instructions, options) - run_options["extra_body"] = {"agent_reference": agent_reference} - - # Remove only keys that map to this client's declared options TypedDict. - self._remove_agent_level_run_options(run_options, options) - - return run_options - - @override - def _check_model_presence(self, options: dict[str, Any]) -> None: - # Skip model check for application endpoints - model is pre-configured on server - if self._is_application_endpoint: - return - if not options.get("model"): - if not self.model_id: - raise ValueError("model_deployment_name must be a non-empty string") - options["model"] = self.model_id - - def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Transform input items to match Azure AI Projects expected schema. - - WORKAROUND: Azure AI Projects 'create responses' API expects a different schema than OpenAI's - Responses API. Azure requires 'type' at the item level, and requires 'annotations' - only for output_text content items (assistant messages), not for input_text content items - (user messages). This helper adapts the OpenAI-style input to the Azure schema. - - See: https://github.com/Azure/azure-sdk-for-python/issues/44493 - TODO(agent-framework#2926): Remove when Azure SDK aligns with OpenAI schema. - """ - transformed: list[dict[str, Any]] = [] - for item in input_items: - new_item: dict[str, Any] = dict(item) - - # Add 'type': 'message' at item level for role-based items - if "role" in new_item and "type" not in new_item: - new_item["type"] = "message" - - # Add 'annotations' only to output_text content items (assistant messages) - # User messages (input_text) do NOT support annotations in Azure AI - if (content := new_item.get("content")) and isinstance(content, list): - new_content: list[Any] = [] - for content_item in content: # type: ignore[list-item] - if isinstance(content_item, MutableMapping): - # Only add annotations to output_text (assistant content) - if content_item.get("type") == "output_text" and "annotations" not in content_item: # type: ignore[reportUnknownMemberType] - content_item["annotations"] = [] - new_content.append(content_item) - else: - new_content.append(content_item) - new_item["content"] = new_content - - transformed.append(new_item) - - return transformed - - @override - def _parse_response_from_openai( - self, - response: Any, - options: dict[str, Any], - ) -> ChatResponse: - """Parse an Azure AI Responses API response, handling Azure-specific output item types.""" - result = super()._parse_response_from_openai(response, options) - - if result.messages: - for item in response.output: - if item.type == "oauth_consent_request": - consent_link = item.consent_link - if consent_link and not consent_link.startswith("https://"): - logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", item) - consent_link = "" - if consent_link: - result.messages[0].contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=item, - ) - ) - else: - logger.warning("Received oauth_consent_request output without consent_link: %s", item) - - return result - - @override - def _parse_chunk_from_openai( - self, - event: Any, - options: dict[str, Any], - function_call_ids: dict[int, tuple[str, str]], - ) -> ChatResponseUpdate: - """Parse an Azure AI streaming event, handling Azure-specific event types.""" - # Intercept output_item.added events for Azure-specific item types - if event.type == "response.output_item.added" and event.item.type == "oauth_consent_request": - event_item = event.item - consent_link = event_item.consent_link - if consent_link and not consent_link.startswith("https://"): - logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", event_item) - consent_link = "" - contents: list[Content] = [] - if consent_link: - contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=event_item, - ) - ) - else: - logger.warning("Received oauth_consent_request output without consent_link: %s", event_item) - return ChatResponseUpdate( - contents=contents, - role="assistant", - model_id=self.model_id, - raw_representation=event, - ) - - return super()._parse_chunk_from_openai(event, options, function_call_ids) - - def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: - """Prepare input from messages and convert system/developer messages to instructions.""" - result: list[Message] = [] - instructions_list: list[str] = [] - instructions: str | None = None - - # System/developer messages are turned into instructions, since there is no such message roles in Azure AI. - for message in messages: - if message.role in ["system", "developer"]: - for text_content in [content for content in message.contents if content.type == "text"]: - instructions_list.append(text_content.text) # type: ignore[arg-type] - else: - result.append(message) - - if len(instructions_list) > 0: - instructions = "".join(instructions_list) - - return result, instructions - - def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None: - """Update the agent name in the chat client. - - Args: - agent_name: The new name for the agent. - description: The new description for the agent. - """ - # This is a no-op in the base class, but can be overridden by subclasses - # to update the agent name in the client. - if agent_name and not self.agent_name: - self.agent_name = agent_name - if description and not self.agent_description: - self.agent_description = description - - # region Azure AI Search Citation Enhancement - - def _extract_azure_search_urls(self, output_items: Any) -> list[str]: - """Extract document URLs from azure_ai_search_call_output items. - - Args: - output_items: The response output items to scan. - - Returns: - A flat list of get_urls from all azure_ai_search_call_output items. - """ - get_urls: list[str] = [] - for item in output_items: - if item.type != "azure_ai_search_call_output": - continue - output = item.output - if isinstance(output, str): - try: - output = json.loads(output) - except (json.JSONDecodeError, TypeError): - continue - if isinstance(output, list): - # Streaming "added" events send output as an empty list; skip. - continue - if output is not None: - urls = output.get("get_urls") if isinstance(output, Mapping) else getattr(output, "get_urls", None) # type: ignore - if isinstance(urls, list): - string_urls: list[str] = [] - for url_item in urls: # type: ignore[list-item] - if isinstance(url_item, str): - string_urls.append(url_item) - get_urls.extend(string_urls) - return get_urls - - def _get_search_doc_url(self, citation_title: str | None, get_urls: list[str]) -> str | None: - """Map a citation title like 'doc_0' to its corresponding get_url. - - Args: - citation_title: The annotation title (e.g., "doc_0"). - get_urls: The list of document URLs from azure_ai_search_call_output. - - Returns: - The matching document URL if found, otherwise None. - """ - if not citation_title or not get_urls: - return None - match = _DOC_INDEX_PATTERN.search(citation_title) - if not match: - return None - doc_index = int(match.group(1)) - if 0 <= doc_index < len(get_urls): - return str(get_urls[doc_index]) - return None - - def _enrich_annotations_with_search_urls(self, contents: list[Content], get_urls: list[str]) -> None: - """Enrich citation annotations in contents with real document URLs from Azure AI Search. - - Looks for annotations with ``type == "citation"`` and a ``title`` matching ``doc_N``, - then adds the corresponding document URL from *get_urls* to ``additional_properties["get_url"]``. - - Args: - contents: The parsed content list from a ChatResponse or ChatResponseUpdate. - get_urls: Document URLs extracted from azure_ai_search_call_output. - """ - if not get_urls: - return - for content in contents: - if not content.annotations: - continue - for annotation in content.annotations: - if not isinstance(annotation, dict): - continue - if annotation.get("type") != "citation": - continue - title = annotation.get("title") - doc_url = self._get_search_doc_url(title, get_urls) - if doc_url: - annotation.setdefault("additional_properties", {})["get_url"] = doc_url - - def _build_url_citation_content( - self, annotation_data: dict[str, Any], get_urls: list[str], raw_event: Any - ) -> Content: - """Build a Content with a citation Annotation from a url_citation streaming event. - - The base class does not handle ``url_citation`` annotations in streaming, so this - method creates the appropriate framework content for them. - - Args: - annotation_data: The raw annotation dict from the streaming event. - get_urls: Captured document URLs for enrichment. - raw_event: The raw streaming event for raw_representation. - - Returns: - A Content object containing the citation annotation. - """ - ann_title = str(annotation_data.get("title") or "") - ann_url = str(annotation_data.get("url") or "") - ann_start = annotation_data.get("start_index") - ann_end = annotation_data.get("end_index") - - additional_props: dict[str, Any] = { - "annotation_index": raw_event.annotation_index, - } - doc_url = self._get_search_doc_url(ann_title, get_urls) - if doc_url: - additional_props["get_url"] = doc_url - - annotation_obj = Annotation( - type="citation", - title=ann_title, - url=ann_url, - additional_properties=additional_props, - raw_representation=annotation_data, - ) - if ann_start is not None and ann_end is not None: - annotation_obj["annotated_regions"] = [ - TextSpanRegion(type="text_span", start_index=ann_start, end_index=ann_end) - ] - - return Content.from_text(text="", annotations=[annotation_obj], raw_representation=raw_event) - - @override - def _inner_get_response( - self, - *, - messages: Sequence[Message], - options: Mapping[str, Any], - stream: bool = False, - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - """Wrap base response to enrich Azure AI Search citation annotations. - - For non-streaming responses, the ``ChatResponse.raw_representation`` carries the - full response including ``azure_ai_search_call_output`` items. After the base class - parses the response, ``url_citation`` annotations are enriched with per-document URLs. - - For streaming responses, a transform hook is registered on the ``ResponseStream`` to - capture ``get_urls`` from search output events and enrich ``url_citation`` annotations - as they arrive. The captured URL state is local to the stream closure, so concurrent - streams do not interfere. - """ - if not stream: - - async def _enrich_response() -> ChatResponse: - response = await super(RawAzureAIClient, self)._inner_get_response( # pyright: ignore[reportDeprecated] - messages=messages, options=options, stream=False, **kwargs - ) - get_urls = self._extract_azure_search_urls(response.raw_representation.output) # type: ignore[union-attr] - if get_urls: - for msg in response.messages: - self._enrich_annotations_with_search_urls(list(msg.contents or []), get_urls) - return response - - return _enrich_response() - - # Streaming: use a closure-local list so concurrent streams don't interfere - stream_result = super()._inner_get_response( # type: ignore[assignment] - messages=messages, options=options, stream=True, **kwargs - ) - search_get_urls: list[str] = [] - - def _enrich_update(update: ChatResponseUpdate) -> ChatResponseUpdate: - raw = update.raw_representation - if raw is None: - return update - event_type = raw.type - - # Capture get_urls from azure_ai_search_call_output items. - # Check both "added" and "done" events because the output data (including - # get_urls) may only be fully populated in the "done" event. - if event_type in ("response.output_item.added", "response.output_item.done"): - urls = self._extract_azure_search_urls([raw.item]) - if urls: - search_get_urls.extend(urls) - - # Handle url_citation annotations (not handled by the base class in streaming) - if event_type == "response.output_text.annotation.added": - ann = raw.annotation - if ann.get("type") == "url_citation": - citation_content = self._build_url_citation_content(ann, search_get_urls, raw) - contents_list = list(update.contents or []) - contents_list.append(citation_content) - return ChatResponseUpdate( - contents=contents_list, - conversation_id=update.conversation_id, - response_id=update.response_id, - role=update.role, # type: ignore[union-attr] - model_id=update.model_id, - continuation_token=update.continuation_token, - additional_properties=update.additional_properties, - raw_representation=update.raw_representation, - ) - - # Enrich any citation annotations already parsed by the base class - if update.contents and search_get_urls: - self._enrich_annotations_with_search_urls(list(update.contents), search_get_urls) - - return update - - stream_result.with_transform_hook(_enrich_update) # type: ignore[union-attr] - return stream_result - - # endregion - - # region Hosted Tool Factory Methods (Azure-specific overrides) - - @staticmethod - def get_code_interpreter_tool( # type: ignore[override] - *, - file_ids: list[str | Content] | None = None, - container: Literal["auto"] | dict[str, Any] = "auto", - **kwargs: Any, - ) -> CodeInterpreterTool: - """Create a code interpreter tool configuration for Azure AI Projects. - - Keyword Args: - file_ids: Optional list of file IDs or Content objects to make available to - the code interpreter. Accepts plain strings or Content.from_hosted_file() - instances. - container: Container configuration. Use "auto" for automatic container management. - Note: Custom container settings from this parameter are not used by Azure AI Projects; - use file_ids instead. - **kwargs: Additional arguments passed to the SDK CodeInterpreterTool constructor. - - Returns: - A CodeInterpreterTool ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_code_interpreter_tool() - agent = ChatAgent(client, tools=[tool]) - """ - # Extract file_ids from container if provided as dict and file_ids not explicitly set - if file_ids is None and isinstance(container, dict): - file_ids = container.get("file_ids") - resolved = resolve_file_ids(file_ids) - tool_container = AutoCodeInterpreterToolParam(file_ids=resolved) - return CodeInterpreterTool(container=tool_container, **kwargs) - - @staticmethod - def get_file_search_tool( - *, - vector_store_ids: list[str], - max_num_results: int | None = None, - ranking_options: dict[str, Any] | None = None, - filters: dict[str, Any] | None = None, - **kwargs: Any, - ) -> ProjectsFileSearchTool: - """Create a file search tool configuration for Azure AI Projects. - - Keyword Args: - vector_store_ids: List of vector store IDs to search. - max_num_results: Maximum number of results to return (1-50). - ranking_options: Ranking options for search results. - filters: A filter to apply (ComparisonFilter or CompoundFilter). - **kwargs: Additional arguments passed to the SDK FileSearchTool constructor. - - Returns: - A FileSearchTool ready to pass to ChatAgent. - - Raises: - ValueError: If vector_store_ids is empty. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_file_search_tool( - vector_store_ids=["vs_abc123"], - ) - agent = ChatAgent(client, tools=[tool]) - """ - if not vector_store_ids: - raise ValueError("File search tool requires 'vector_store_ids' to be specified.") - return ProjectsFileSearchTool( - vector_store_ids=vector_store_ids, - max_num_results=max_num_results, - ranking_options=ranking_options, # type: ignore[arg-type] - filters=filters, # type: ignore[arg-type] - **kwargs, - ) - - @staticmethod - def get_web_search_tool( # type: ignore[override] - *, - user_location: dict[str, str] | None = None, - search_context_size: Literal["low", "medium", "high"] | None = None, - **kwargs: Any, - ) -> WebSearchPreviewTool: - """Create a web search preview tool configuration for Azure AI Projects. - - Keyword Args: - user_location: Location context for search results. Dict with keys like - "city", "country", "region", "timezone". - search_context_size: Amount of context to include from search results. - One of "low", "medium", or "high". Defaults to "medium". - **kwargs: Additional arguments passed to the SDK WebSearchPreviewTool constructor. - - Returns: - A WebSearchPreviewTool ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_web_search_tool() - agent = ChatAgent(client, tools=[tool]) - - # With location and context size - tool = AzureAIClient.get_web_search_tool( - user_location={"city": "Seattle", "country": "US"}, - search_context_size="high", - ) - """ - ws_tool = WebSearchPreviewTool(search_context_size=search_context_size, **kwargs) - - if user_location: - ws_tool.user_location = ApproximateLocation( - city=user_location.get("city"), - country=user_location.get("country"), - region=user_location.get("region"), - timezone=user_location.get("timezone"), - ) - - return ws_tool - - @staticmethod - def get_image_generation_tool( # type: ignore[override] - *, - model: Literal["gpt-image-1"] | str | None = None, - size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None, - output_format: Literal["png", "webp", "jpeg"] | None = None, - quality: Literal["low", "medium", "high", "auto"] | None = None, - background: Literal["transparent", "opaque", "auto"] | None = None, - partial_images: int | None = None, - moderation: Literal["auto", "low"] | None = None, - output_compression: int | None = None, - **kwargs: Any, - ) -> ImageGenTool: - """Create an image generation tool configuration for Azure AI Projects. - - Keyword Args: - model: The model to use for image generation. - size: Output image size. - output_format: Output image format. - quality: Output image quality. - background: Background transparency setting. - partial_images: Number of partial images to return during generation. - moderation: Moderation level. - output_compression: Compression level. - **kwargs: Additional arguments passed to the SDK ImageGenTool constructor. - - Returns: - An ImageGenTool ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_image_generation_tool() - agent = ChatAgent(client, tools=[tool]) - """ - return ImageGenTool( # type: ignore[misc] - model=model, # type: ignore[arg-type] - size=size, - output_format=output_format, - quality=quality, - background=background, - partial_images=partial_images, - moderation=moderation, - output_compression=output_compression, - **kwargs, - ) - - @staticmethod - def get_mcp_tool( - *, - name: str, - url: str | None = None, - description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None, - allowed_tools: list[str] | None = None, - headers: dict[str, str] | None = None, - project_connection_id: str | None = None, - **kwargs: Any, - ) -> MCPTool: - """Create a hosted MCP tool configuration for Azure AI. - - This configures an MCP (Model Context Protocol) server that will be called - by Azure AI's service. The tools from this MCP server are executed remotely - by Azure AI, not locally by your application. - - Note: - For local MCP execution where your application calls the MCP server - directly, use the MCP client tools instead of this method. - - Keyword Args: - name: A label/name for the MCP server. - url: The URL of the MCP server. Required if project_connection_id is not provided. - description: A description of what the MCP server provides. - approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools, - or provide a dict with "always_require_approval" and/or "never_require_approval" - keys mapping to lists of tool names. - allowed_tools: List of tool names that are allowed to be used from this MCP server. - headers: HTTP headers to include in requests to the MCP server. - project_connection_id: Azure AI Foundry connection ID for managed MCP connections. - If provided, url and headers are not required. - **kwargs: Additional arguments passed to the SDK MCPTool constructor. - - Returns: - An MCPTool configuration ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - # With URL - tool = AzureAIClient.get_mcp_tool( - name="my_mcp", - url="https://mcp.example.com", - ) - - # With Azure AI Foundry connection - tool = AzureAIClient.get_mcp_tool( - name="github_mcp", - project_connection_id="conn_abc123", - description="GitHub MCP via Azure AI Foundry", - ) - - agent = ChatAgent(client, tools=[tool]) - """ - mcp = MCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs) - - if description: - mcp["server_description"] = description - - if project_connection_id: - mcp["project_connection_id"] = project_connection_id - elif headers: - mcp["headers"] = headers - - if allowed_tools: - mcp["allowed_tools"] = allowed_tools - - if approval_mode: - if isinstance(approval_mode, str): - mcp["require_approval"] = "always" if approval_mode == "always_require" else "never" - else: - if always_require := approval_mode.get("always_require_approval"): - mcp["require_approval"] = {"always": {"tool_names": always_require}} - if never_require := approval_mode.get("never_require_approval"): - mcp["require_approval"] = {"never": {"tool_names": never_require}} - - return mcp - - # endregion - - @override - def as_agent( - self, - *, - id: str | None = None, - name: str | None = None, - description: str | None = None, - instructions: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: AzureAIClientOptionsT | Mapping[str, Any] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - **kwargs: Any, - ) -> Agent[AzureAIClientOptionsT]: - """Convert this chat client to a Agent. - - This method creates a Agent instance with this client pre-configured. - It does NOT create an agent on the Azure AI service - the actual agent - will be created on the server during the first invocation (run). - - For working with pre-configured persistent agents on the server, use - :class:`~agent_framework_azure_ai.FoundryAgent` instead. - - Keyword Args: - id: The unique identifier for the agent. Will be created automatically if not provided. - name: The name of the agent. Defaults to the client's ``agent_name`` when None. - description: A brief description of the agent's purpose. Defaults to the client's - ``agent_description`` when None. - instructions: Optional instructions for the agent. - tools: The tools to use for the request. - default_options: A TypedDict containing chat options. - context_providers: Context providers to include during agent invocation. - middleware: List of middleware to intercept agent and function invocations. - kwargs: Any additional keyword arguments. - - Returns: - A Agent instance configured with this chat client. - """ - return super().as_agent( - id=id, - name=self.agent_name if name is None else name, - description=self.agent_description if description is None else description, - instructions=instructions, - tools=tools, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - **kwargs, - ) - - -@deprecated("AzureAIClient is deprecated. Use FoundryAgent instead.") -class AzureAIClient( - FunctionInvocationLayer[AzureAIClientOptionsT], - ChatMiddlewareLayer[AzureAIClientOptionsT], - ChatTelemetryLayer[AzureAIClientOptionsT], - RawAzureAIClient[AzureAIClientOptionsT], # pyright: ignore[reportDeprecated] - Generic[AzureAIClientOptionsT], -): - """Deprecated Azure AI client with middleware, telemetry, and function invocation support. - - This class is deprecated. Use ``FoundryAgent`` instead for connecting to - pre-configured agents in Foundry. It includes: - - Chat middleware support for request/response interception - - OpenTelemetry-based telemetry for observability - - Automatic function/tool invocation handling - - For a minimal implementation without these features, use :class:`RawFoundryAgentChatClient`. - """ - - def __init__( - self, - *, - project_client: AIProjectClient | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - agent_description: str | None = None, - conversation_id: str | None = None, - project_endpoint: str | None = None, - model_deployment_name: str | None = None, - credential: AzureCredentialTypes | None = None, - use_latest_version: bool | None = None, - allow_preview: bool | None = None, - additional_properties: dict[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure AI client with full layer support. - - Keyword Args: - project_client: An existing AIProjectClient to use. If not provided, one will be created. - agent_name: The name to use when creating new agents or using existing agents. - agent_version: The version of the agent to use. - agent_description: The description to use when creating new agents. - conversation_id: Default conversation ID to use for conversations. Can be overridden by - conversation_id property when making a request. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a project_client is passed. - model_deployment_name: The model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential - or AsyncTokenCredential. - use_latest_version: Boolean flag that indicates whether to use latest agent version - if it exists in the service. - allow_preview: Enables preview opt-in on internally-created ``AIProjectClient`` - additional_properties: Additional properties stored on the client instance. - middleware: Optional sequence of chat middlewares to include. - function_invocation_configuration: Optional function invocation configuration. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Examples: - .. code-block:: python - - from agent_framework_azure_ai import AzureAIClient - from azure.identity.aio import DefaultAzureCredential - - # Using environment variables - # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 - credential = DefaultAzureCredential() - client = AzureAIClient(credential=credential) - - # Or passing parameters directly - client = AzureAIClient( - project_endpoint="https://your-project.cognitiveservices.azure.com", - model_deployment_name="gpt-4", - credential=credential, - ) - - # Or loading from a .env file - client = AzureAIClient(credential=credential, env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework import ChatOptions - - - class MyOptions(ChatOptions, total=False): - my_custom_option: str - - - client: AzureAIClient[MyOptions] = AzureAIClient(credential=credential) - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - super().__init__( - project_client=project_client, - agent_name=agent_name, - agent_version=agent_version, - agent_description=agent_description, - conversation_id=conversation_id, - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - credential=credential, - use_latest_version=use_latest_version, - allow_preview=allow_preview, - additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py deleted file mode 100644 index 8a3a9833ac..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py +++ /dev/null @@ -1,918 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Deprecated Azure OpenAI client classes. - -All classes in this module are deprecated and will be removed in a future release. -Migrate to the ``agent_framework_openai`` package equivalents with an ``AsyncAzureOpenAI`` client, -or use ``FoundryChatClient`` for Azure AI Foundry projects. -""" - -from __future__ import annotations - -import json -import logging -import sys -from collections.abc import Mapping, Sequence -from contextlib import contextmanager -from copy import copy -from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast -from urllib.parse import urljoin, urlparse - -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import SecretString, load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, prepend_agent_framework_to_user_agent -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer -from agent_framework._types import Annotation, Content -from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer -from agent_framework_openai._assistants_client import ( - OpenAIAssistantsClient, # type: ignore[reportDeprecated] - OpenAIAssistantsOptions, -) -from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient -from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient -from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient -from agent_framework_openai._shared import OpenAIBase -from azure.ai.projects.aio import AIProjectClient -from openai import AsyncOpenAI -from openai.lib.azure import AsyncAzureOpenAI -from pydantic import BaseModel - -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - from openai.types.chat.chat_completion import Choice - from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice - -logger: logging.Logger = logging.getLogger(__name__) - - -# region Constants and Settings - -DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21" -DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105 - - -class AzureOpenAISettings(TypedDict, total=False): - """AzureOpenAI model settings. - - Settings are resolved in this order: explicit keyword arguments, values from an - explicitly provided .env file, then environment variables with the prefix - 'AZURE_OPENAI_'. If settings are missing after resolution, validation will fail. - - Keyword Args: - endpoint: The endpoint of the Azure deployment. - chat_deployment_name: The name of the Azure Chat deployment. - responses_deployment_name: The name of the Azure Responses deployment. - embedding_deployment_name: The name of the Azure Embedding deployment. - api_key: The API key for the Azure deployment. - api_version: The API version to use. - base_url: The url of the Azure deployment. - token_endpoint: The token endpoint to use to retrieve the authentication token. - """ - - chat_deployment_name: str | None - responses_deployment_name: str | None - embedding_deployment_name: str | None - endpoint: str | None - base_url: str | None - api_key: SecretString | None - api_version: str | None - token_endpoint: str | None - - -def _apply_azure_defaults( - settings: AzureOpenAISettings, - default_api_version: str = DEFAULT_AZURE_API_VERSION, - default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT, -) -> None: - """Apply default values for api_version and token_endpoint after loading settings. - - Args: - settings: The loaded Azure OpenAI settings dict. - default_api_version: The default API version to use if not set. - default_token_endpoint: The default token endpoint to use if not set. - """ - if not settings.get("api_version"): - settings["api_version"] = default_api_version - if not settings.get("token_endpoint"): - settings["token_endpoint"] = default_token_endpoint - - -@contextmanager -def _prefer_single_azure_endpoint_env(*, endpoint: str | None, base_url: str | None) -> Any: - """Preserve the legacy call shape without mutating process-wide environment state.""" - yield - - -# endregion - - -# region AzureOpenAIConfigMixin - - -class AzureOpenAIConfigMixin(OpenAIBase): - """Internal class for configuring a connection to an Azure OpenAI service.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - deployment_name: str, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str = DEFAULT_AZURE_API_VERSION, - api_key: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - client: AsyncOpenAI | None = None, - instruction_role: str | None = None, - **kwargs: Any, - ) -> None: - """Configure a connection to an Azure OpenAI service. - - Args: - deployment_name: Name of the deployment. - endpoint: The specific endpoint URL for the deployment. - base_url: The base URL for Azure services. - api_version: Azure API version. - api_key: API key for Azure services. - token_endpoint: Azure AD token scope. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - client: An existing client to use. - instruction_role: The role to use for 'instruction' messages. - kwargs: Additional keyword arguments. - """ - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - if not client: - ad_token_provider = None - if not api_key and credential: - ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint) - - if not api_key and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint and not base_url: - raise ValueError("Please provide an endpoint or a base_url") - - args: dict[str, Any] = { - "default_headers": merged_headers, - } - if api_version: - args["api_version"] = api_version - if ad_token_provider: - args["azure_ad_token_provider"] = ad_token_provider - if api_key: - args["api_key"] = api_key - if base_url: - args["base_url"] = str(base_url) - if endpoint and not base_url: - args["azure_endpoint"] = str(endpoint) - if deployment_name: - args["azure_deployment"] = deployment_name - if "websocket_base_url" in kwargs: - args["websocket_base_url"] = kwargs.pop("websocket_base_url") - - client = AsyncAzureOpenAI(**args) - - self.endpoint = str(endpoint) - self.base_url = str(base_url) - self.api_version = api_version - self.deployment_name = deployment_name - self.instruction_role = instruction_role - if default_headers: - from agent_framework._telemetry import USER_AGENT_KEY - - def_headers = {k: v for k, v in default_headers.items() if k != USER_AGENT_KEY} - else: - def_headers = None - self.default_headers = def_headers - - super().__init__(model_id=deployment_name, client=client, **kwargs) - - -# endregion - - -# region AzureOpenAIResponsesClient - - -AzureOpenAIResponsesOptionsT = TypeVar( - "AzureOpenAIResponsesOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIChatOptions", - covariant=True, -) - -AzureOpenAIResponsesOptions = OpenAIChatOptions - - -@deprecated( - "AzureOpenAIResponsesClient is deprecated. " - "Use OpenAIChatClient with an AsyncAzureOpenAI client, or FoundryChatClient for Foundry projects." -) -class AzureOpenAIResponsesClient( # type: ignore[misc] - FunctionInvocationLayer[AzureOpenAIResponsesOptionsT], - ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], - ChatTelemetryLayer[AzureOpenAIResponsesOptionsT], - RawOpenAIChatClient[AzureOpenAIResponsesOptionsT], - Generic[AzureOpenAIResponsesOptionsT], -): - """Deprecated Azure Responses client. Use OpenAIChatClient with an AsyncAzureOpenAI client instead.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, - project_client: Any | None = None, - project_endpoint: str | None = None, - allow_preview: bool | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - instruction_role: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, - ) -> None: - """Initialize an Azure OpenAI Responses client. - - Keyword Args: - api_key: The API key. - deployment_name: The deployment name. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - project_client: An existing AIProjectClient to use. - project_endpoint: The Azure AI Foundry project endpoint URL. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - instruction_role: The role to use for 'instruction' messages. - middleware: Optional sequence of middleware. - function_invocation_configuration: Optional function invocation configuration. - kwargs: Additional keyword arguments. - """ - if (model_id := kwargs.pop("model_id", None)) and not deployment_name: - deployment_name = str(model_id) - - if async_client is None and (project_client is not None or project_endpoint is not None): - async_client = self._create_client_from_project( - project_client=project_client, - project_endpoint=project_endpoint, - credential=credential, - allow_preview=allow_preview, - ) - - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - responses_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings, default_api_version="preview") - endpoint_value = azure_openai_settings.get("endpoint") - if ( - not azure_openai_settings.get("base_url") - and endpoint_value - and (hostname := urlparse(str(endpoint_value)).hostname) - and hostname.endswith(".openai.azure.com") - ): - azure_openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") - - responses_deployment_name = azure_openai_settings.get("responses_deployment_name") - if not responses_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." - ) - - endpoint_value = azure_openai_settings.get("endpoint") - client_base_url = azure_openai_settings.get("base_url") - if not async_client: - # Create the Azure OpenAI client directly - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - api_key_secret = azure_openai_settings.get("api_key") - ad_token_provider = None - if not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider( - credential, azure_openai_settings.get("token_endpoint") - ) - - if not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint_value and not client_base_url: - raise ValueError("Please provide an endpoint or a base_url") - - client_args: dict[str, Any] = {"default_headers": merged_headers} - if resolved_api_version := azure_openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if ad_token_provider: - client_args["azure_ad_token_provider"] = ad_token_provider - if api_key_secret: - client_args["api_key"] = api_key_secret.get_secret_value() - if client_base_url: - client_args["base_url"] = str(client_base_url) - if endpoint_value and not client_base_url: - client_args["azure_endpoint"] = str(endpoint_value) - if responses_deployment_name: - client_args["azure_deployment"] = responses_deployment_name - if "websocket_base_url" in kwargs: - client_args["websocket_base_url"] = kwargs.pop("websocket_base_url") - - async_client = AsyncAzureOpenAI(**client_args) - - # Store Azure-specific attributes for serialization - self.endpoint = str(endpoint_value) if endpoint_value else None - self.api_version = azure_openai_settings.get("api_version") or "" - self.deployment_name = responses_deployment_name - - with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=client_base_url): - super().__init__( - async_client=async_client, - model=responses_deployment_name, - azure_endpoint=str(endpoint_value) if endpoint_value else None, - base_url=str(client_base_url) if client_base_url else None, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - **kwargs, - ) - - @staticmethod - def _create_client_from_project( - *, - project_client: AIProjectClient | None, - project_endpoint: str | None, - credential: AzureCredentialTypes | AzureTokenProvider | None, - allow_preview: bool | None = None, - ) -> AsyncOpenAI: - """Create an AsyncOpenAI client from an Azure AI Foundry project.""" - if project_client is not None: - return project_client.get_openai_client() - - if not project_endpoint: - raise ValueError("Azure AI project endpoint is required when project_client is not provided.") - if not credential: - raise ValueError("Azure credential is required when using project_endpoint without a project_client.") - project_client_kwargs: dict[str, Any] = { - "endpoint": project_endpoint, - "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, - } - if allow_preview is not None: - project_client_kwargs["allow_preview"] = allow_preview - project_client = AIProjectClient(**project_client_kwargs) - return project_client.get_openai_client() - - @override - def _check_model_presence(self, options: dict[str, Any]) -> None: - if not options.get("model"): - if not self.model: - raise ValueError("deployment_name must be a non-empty string") - options["model"] = self.model - - -# endregion - - -# region AzureOpenAIChatClient - - -ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) - - -class AzureUserSecurityContext(TypedDict, total=False): - """User security context for Azure AI applications. - - These fields help security operations teams investigate and mitigate security - incidents by providing context about the application and end user. - """ - - application_name: str - """Name of the application making the request.""" - - end_user_id: str - """Unique identifier for the end user (recommend hashing username/email).""" - - end_user_tenant_id: str - """Microsoft 365 tenant ID the end user belongs to. Required for multi-tenant apps.""" - - source_ip: str - """The original client's IP address.""" - - -class AzureOpenAIChatOptions(OpenAIChatCompletionOptions[ResponseModelT], Generic[ResponseModelT], total=False): - """Azure OpenAI-specific chat options dict. - - Extends OpenAIChatCompletionOptions with Azure-specific options including - the "On Your Data" feature and enhanced security context. - """ - - data_sources: list[dict[str, Any]] - """Azure "On Your Data" data sources for retrieval-augmented generation.""" - - user_security_context: AzureUserSecurityContext - """Enhanced security context for Azure Defender integration.""" - - n: int - """Number of chat completion choices to generate for each input message.""" - - -AzureOpenAIChatOptionsT = TypeVar( - "AzureOpenAIChatOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureOpenAIChatOptions", - covariant=True, -) - - -@deprecated("AzureOpenAIChatClient is deprecated. Use OpenAIChatCompletionClient with an AsyncAzureOpenAI client.") -class AzureOpenAIChatClient( # type: ignore[misc] - FunctionInvocationLayer[AzureOpenAIChatOptionsT], - ChatMiddlewareLayer[AzureOpenAIChatOptionsT], - ChatTelemetryLayer[AzureOpenAIChatOptionsT], - RawOpenAIChatCompletionClient[AzureOpenAIChatOptionsT], - Generic[AzureOpenAIChatOptionsT], -): - """Deprecated Azure OpenAI Chat client. Use OpenAIChatCompletionClient with AsyncAzureOpenAI instead.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - additional_properties: dict[str, Any] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - instruction_role: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: - """Initialize an Azure OpenAI Chat completion client. - - Keyword Args: - api_key: The API key. - deployment_name: The deployment name. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - additional_properties: Additional properties stored on the client instance. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - instruction_role: The role to use for 'instruction' messages. - middleware: Optional sequence of middleware. - function_invocation_configuration: Optional function invocation configuration. - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings) - - chat_deployment_name = azure_openai_settings.get("chat_deployment_name") - if not chat_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." - ) - - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") - if not async_client: - # Create the Azure OpenAI client directly - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - api_key_secret = azure_openai_settings.get("api_key") - ad_token_provider = None - if not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider( - credential, azure_openai_settings.get("token_endpoint") - ) - - if not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint_value and not base_url_value: - raise ValueError("Please provide an endpoint or a base_url") - - client_args: dict[str, Any] = {"default_headers": merged_headers} - if resolved_api_version := azure_openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if ad_token_provider: - client_args["azure_ad_token_provider"] = ad_token_provider - if api_key_secret: - client_args["api_key"] = api_key_secret.get_secret_value() - if base_url_value: - client_args["base_url"] = str(base_url_value) - if endpoint_value and not base_url_value: - client_args["azure_endpoint"] = str(endpoint_value) - if chat_deployment_name: - client_args["azure_deployment"] = chat_deployment_name - - async_client = AsyncAzureOpenAI(**client_args) - - # Store Azure-specific attributes for serialization - self.endpoint = str(azure_openai_settings.get("endpoint") or "") - self.api_version = azure_openai_settings.get("api_version") or "" - self.deployment_name = chat_deployment_name - - with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): - super().__init__( - async_client=async_client, - model=chat_deployment_name, - azure_endpoint=str(endpoint_value) if endpoint_value else None, - base_url=str(base_url_value) if base_url_value else None, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - additional_properties=additional_properties, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - ) - - @override - def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: - """Parse the choice into a Content object with type='text'. - - Overwritten from RawOpenAIChatCompletionClient to deal with Azure On Your Data function. - """ - message = getattr(choice, "message", None) - if message is None: - message = getattr(choice, "delta", None) - if message is None: # type: ignore - return None - if hasattr(message, "refusal") and message.refusal: - return Content.from_text(text=message.refusal, raw_representation=choice) - if not message.content: - return None - text_content = Content.from_text(text=message.content, raw_representation=choice) - if not message.model_extra or "context" not in message.model_extra: - return text_content - - context_raw: object = cast(object, message.context) # type: ignore[union-attr] - if isinstance(context_raw, str): - try: - context_raw = json.loads(context_raw) - except json.JSONDecodeError: - logger.warning("Context is not a valid JSON string, ignoring context.") - return text_content - if not isinstance(context_raw, dict): - logger.warning("Context is not a valid dictionary, ignoring context.") - return text_content - context = cast(dict[str, Any], context_raw) - if intent := context.get("intent"): - text_content.additional_properties = {"intent": intent} - citations = context.get("citations") - if isinstance(citations, list) and citations: - annotations: list[Annotation] = [] - for citation_raw in cast(list[object], citations): - if not isinstance(citation_raw, dict): - continue - citation = cast(dict[str, Any], citation_raw) - annotations.append( - Annotation( - type="citation", - title=citation.get("title", ""), - url=citation.get("url", ""), - snippet=citation.get("content", ""), - file_id=citation.get("filepath", ""), - tool_name="Azure-on-your-Data", - additional_properties={"chunk_id": citation.get("chunk_id", "")}, - raw_representation=citation, - ) - ) - text_content.annotations = annotations - return text_content - - -# endregion - - -# region AzureOpenAIAssistantsClient - - -AzureOpenAIAssistantsOptionsT = TypeVar( - "AzureOpenAIAssistantsOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIAssistantsOptions", - covariant=True, -) - -AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions - - -@deprecated( - "AzureOpenAIAssistantsClient is deprecated. " - "Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient." -) -class AzureOpenAIAssistantsClient( - OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], # type: ignore[reportDeprecated] - Generic[AzureOpenAIAssistantsOptionsT], -): - """Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient.""" - - DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview" - - def __init__( - self, - *, - deployment_name: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - assistant_description: str | None = None, - thread_id: str | None = None, - api_key: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure OpenAI Assistants client. - - Keyword Args: - deployment_name: The Azure OpenAI deployment name. - assistant_id: The ID of an Azure OpenAI assistant to use. - assistant_name: The name to use when creating new assistants. - assistant_description: The description to use when creating new assistants. - thread_id: Default thread ID to use for conversations. - api_key: The API key to use. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION) - - chat_deployment_name = azure_openai_settings.get("chat_deployment_name") - if not chat_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." - ) - - api_key_secret = azure_openai_settings.get("api_key") - token_scope = azure_openai_settings.get("token_endpoint") - - ad_token_provider = None - if not async_client and not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider(credential, token_scope) - - if not async_client and not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not async_client: - client_params: dict[str, Any] = { - "default_headers": default_headers, - } - if resolved_api_version := azure_openai_settings.get("api_version"): - client_params["api_version"] = resolved_api_version - - if api_key_secret: - client_params["api_key"] = api_key_secret.get_secret_value() - elif ad_token_provider: - client_params["azure_ad_token_provider"] = ad_token_provider - - if resolved_base_url := azure_openai_settings.get("base_url"): - client_params["base_url"] = str(resolved_base_url) - elif resolved_endpoint := azure_openai_settings.get("endpoint"): - client_params["azure_endpoint"] = str(resolved_endpoint) - - async_client = AsyncAzureOpenAI(**client_params) - - super().__init__( - model_id=chat_deployment_name, - assistant_id=assistant_id, - assistant_name=assistant_name, - assistant_description=assistant_description, - thread_id=thread_id, - async_client=async_client, # type: ignore[reportArgumentType] - default_headers=default_headers, - ) - - -# endregion - - -# region AzureOpenAIEmbeddingClient - - -AzureOpenAIEmbeddingOptionsT = TypeVar( - "AzureOpenAIEmbeddingOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIEmbeddingOptions", - covariant=True, -) - - -@deprecated("AzureOpenAIEmbeddingClient is deprecated. Use OpenAIEmbeddingClient with an AsyncAzureOpenAI client.") -class AzureOpenAIEmbeddingClient( - EmbeddingTelemetryLayer[str, list[float], AzureOpenAIEmbeddingOptionsT], - RawOpenAIEmbeddingClient[AzureOpenAIEmbeddingOptionsT], - Generic[AzureOpenAIEmbeddingOptionsT], -): - """Deprecated Azure OpenAI embedding client. Use OpenAIEmbeddingClient with AsyncAzureOpenAI instead.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - otel_provider_name: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure OpenAI embedding client. - - Keyword Args: - api_key: The API key. - deployment_name: The deployment name. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - otel_provider_name: Override the OpenTelemetry provider name. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - embedding_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings) - - embedding_deployment_name = azure_openai_settings.get("embedding_deployment_name") - if not embedding_deployment_name: - raise ValueError( - "Azure OpenAI embedding deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable." - ) - - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") - if not async_client: - # Create the Azure OpenAI client directly - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - api_key_secret = azure_openai_settings.get("api_key") - ad_token_provider = None - if not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider( - credential, azure_openai_settings.get("token_endpoint") - ) - - if not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint_value and not base_url_value: - raise ValueError("Please provide an endpoint or a base_url") - - client_args: dict[str, Any] = {"default_headers": merged_headers} - if resolved_api_version := azure_openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if ad_token_provider: - client_args["azure_ad_token_provider"] = ad_token_provider - if api_key_secret: - client_args["api_key"] = api_key_secret.get_secret_value() - if base_url_value: - client_args["base_url"] = str(base_url_value) - if endpoint_value and not base_url_value: - client_args["azure_endpoint"] = str(endpoint_value) - if embedding_deployment_name: - client_args["azure_deployment"] = embedding_deployment_name - - async_client = AsyncAzureOpenAI(**client_args) - - # Store Azure-specific attributes for serialization - self.endpoint = str(azure_openai_settings.get("endpoint") or "") - self.api_version = azure_openai_settings.get("api_version") or "" - self.deployment_name = embedding_deployment_name - - with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): - super().__init__( - async_client=async_client, - model=embedding_deployment_name, - azure_endpoint=str(endpoint_value) if endpoint_value else None, - base_url=str(base_url_value) if base_url_value else None, - api_version=azure_openai_settings.get("api_version"), - default_headers=default_headers, - ) - if otel_provider_name is not None: - self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] - - -# endregion diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_entra_id_authentication.py b/python/packages/azure-ai/agent_framework_azure_ai/_entra_id_authentication.py deleted file mode 100644 index b1ae8a4739..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_entra_id_authentication.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable -from typing import Union - -from agent_framework.exceptions import ChatClientInvalidAuthException -from azure.core.credentials import TokenCredential -from azure.core.credentials_async import AsyncTokenCredential - -logger: logging.Logger = logging.getLogger(__name__) - -AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] -"""A callable that returns a bearer token string, either synchronously or asynchronously.""" - -AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential] -"""Union of Azure credential types. - -Accepts: -- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``) -- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``) -""" - - -def resolve_credential_to_token_provider( - credential: AzureCredentialTypes | AzureTokenProvider, - token_endpoint: str | None, -) -> AzureTokenProvider: - """Convert an Azure credential or token provider into an ``ad_token_provider`` callable. - - If the credential is already a callable token provider, it is returned as-is - (``token_endpoint`` is not required in this case). - If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using - ``azure.identity.get_bearer_token_provider`` (sync or async variant) which - handles token caching and automatic refresh. - - Args: - credential: An Azure credential or token provider callable. - token_endpoint: The token scope/endpoint - (e.g. ``"https://cognitiveservices.azure.com/.default"``). - Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``. - - Returns: - A callable that returns a bearer token string (sync or async). - - Raises: - ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping. - """ - # Already a token provider callable (not a credential object) — use directly - if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)): - return credential - - if not token_endpoint: - raise ChatClientInvalidAuthException( - "A token endpoint must be provided either in settings, as an environment variable, or as an argument." - ) - - if isinstance(credential, AsyncTokenCredential): - from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider - - return get_async_bearer_token_provider(credential, token_endpoint) - - from azure.identity import get_bearer_token_provider - - return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py deleted file mode 100644 index c0430fd47c..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ /dev/null @@ -1,488 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -import sys -from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import Any, Generic, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - BaseContextProvider, - FunctionTool, - MiddlewareTypes, - normalize_tools, -) -from agent_framework._mcp import MCPTool -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import ( - AgentVersionDetails, - PromptAgentDefinition, - PromptAgentDefinitionTextOptions, -) -from azure.ai.projects.models import ( - FunctionTool as AzureFunctionTool, -) - -from ._client import AzureAIClient, AzureAIProjectAgentOptions # pyright: ignore[reportDeprecated] -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - - -logger = logging.getLogger("agent_framework.azure") - - -# Type variable for options - allows typed Agent[OptionsT] returns -# Default matches AzureAIClient's default options type -OptionsCoT = TypeVar( - "OptionsCoT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIProjectAgentOptions", - covariant=True, -) - - -@deprecated("AzureAIProjectAgentProvider is deprecated. Use FoundryAgent instead.") -class AzureAIProjectAgentProvider(Generic[OptionsCoT]): - """Deprecated provider for Azure AI Agent Service (Responses API). - - This provider is deprecated. Use ``FoundryAgent`` instead to connect to - pre-configured agents in Foundry. - - Examples: - Using with explicit AIProjectClient: - - .. code-block:: python - - from agent_framework.azure import AzureAIProjectAgentProvider - from azure.ai.projects.aio import AIProjectClient - from azure.identity.aio import DefaultAzureCredential - - async with AIProjectClient(endpoint, credential) as client: - provider = AzureAIProjectAgentProvider(client) - agent = await provider.create_agent( - name="MyAgent", - model="gpt-4", - instructions="You are a helpful assistant.", - ) - response = await agent.run("Hello!") - - Using with credential and endpoint (auto-creates client): - - .. code-block:: python - - from agent_framework.azure import AzureAIProjectAgentProvider - from azure.identity.aio import DefaultAzureCredential - - async with AzureAIProjectAgentProvider(credential=credential) as provider: - agent = await provider.create_agent( - name="MyAgent", - model="gpt-4", - instructions="You are a helpful assistant.", - ) - response = await agent.run("Hello!") - """ - - def __init__( - self, - project_client: AIProjectClient | None = None, - *, - project_endpoint: str | None = None, - model: str | None = None, - credential: AzureCredentialTypes | None = None, - allow_preview: bool | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure AI Project Agent Provider. - - Args: - project_client: An existing AIProjectClient to use. If not provided, one will be created. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a project_client is passed. - model: The default model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - Required when project_client is not provided. - allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Raises: - ValueError: If required parameters are missing or invalid. - """ - self._settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - model_deployment_name=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - # Track whether we should close client connection - self._should_close_client = False - - if project_client is None: - resolved_endpoint = self._settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " - "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - - if not credential: - raise ValueError("Azure credential is required when project_client is not provided.") - - project_client_kwargs: dict[str, Any] = { - "endpoint": resolved_endpoint, - "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, - } - if allow_preview is not None: - project_client_kwargs["allow_preview"] = allow_preview - project_client = AIProjectClient(**project_client_kwargs) - self._should_close_client = True - - self._project_client = project_client - - async def create_agent( - self, - name: str, - model: str | None = None, - instructions: str | None = None, - description: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a new agent on the Azure AI service and return a local Agent wrapper. - - Args: - name: The name of the agent to create. - model: The model deployment name to use. Falls back to AZURE_AI_MODEL_DEPLOYMENT_NAME - environment variable if not provided. - instructions: Instructions for the agent. - description: A description of the agent. - tools: Tools to make available to the agent. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the created agent. - - Raises: - ValueError: If required parameters are missing. - """ - # Resolve model from parameter or environment variable - resolved_model = model or self._settings.get("model_deployment_name") - if not resolved_model: - raise ValueError( - "Model deployment name is required. Provide 'model' parameter " - "or set 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable." - ) - - # Extract options from default_options if present - opts: dict[str, Any] = dict(default_options) if default_options else {} - response_format = opts.get("response_format") - rai_config = opts.get("rai_config") - reasoning = opts.get("reasoning") - - args: dict[str, Any] = {"model": resolved_model} - - if instructions: - args["instructions"] = instructions - if response_format and isinstance(response_format, (type, dict)): - args["text"] = PromptAgentDefinitionTextOptions( - format=create_text_format_config(response_format) # type: ignore[arg-type] - ) - if rai_config: - args["rai_config"] = rai_config - if reasoning: - args["reasoning"] = reasoning - - # Normalize tools and separate MCP tools from other tools - normalized_tools = normalize_tools(tools) - mcp_tools: list[MCPTool] = [] - non_mcp_tools: list[FunctionTool | MutableMapping[str, Any]] = [] - - if normalized_tools: - for tool in normalized_tools: - if isinstance(tool, MCPTool): - mcp_tools.append(tool) - elif isinstance(tool, (FunctionTool, MutableMapping)): - non_mcp_tools.append(tool) # type: ignore[reportUnknownArgumentType] - - # Connect MCP tools and discover their functions BEFORE creating the agent - # This is required because Azure AI Responses API doesn't accept tools at request time - mcp_discovered_functions: list[FunctionTool] = [] - for mcp_tool in mcp_tools: - if not mcp_tool.is_connected: - await mcp_tool.connect() - mcp_discovered_functions.extend(mcp_tool.functions) - - # Combine non-MCP tools with discovered MCP functions for Azure AI - all_tools_for_azure: list[FunctionTool | MutableMapping[str, Any]] = list(non_mcp_tools) - all_tools_for_azure.extend(mcp_discovered_functions) - - if all_tools_for_azure: - args["tools"] = to_azure_ai_tools(all_tools_for_azure) - - create_version_kwargs: dict[str, Any] = { - "agent_name": name, - "definition": PromptAgentDefinition(**args), - "description": description, - } - - created_agent = await self._project_client.agents.create_version(**create_version_kwargs) - - return self._to_chat_agent_from_details( - created_agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - async def get_agent( - self, - *, - name: str | None = None, - reference: Mapping[str, str | None] | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Retrieve an existing agent from the Azure AI service and return a local Agent wrapper. - - You must provide either name or reference. Use `as_agent()` if you already have - AgentVersionDetails and want to avoid an async call. - - Args: - name: The name of the agent to retrieve (fetches latest version). - reference: Mapping containing the agent's ``name`` and optionally a specific ``version``. - tools: Tools to make available to the agent. Required if the agent has function tools. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the retrieved agent. - - Raises: - ValueError: If no identifier is provided or required tools are missing. - """ - existing_agent: AgentVersionDetails - - reference_name = str(reference.get("name")) if reference and reference.get("name") else None - reference_version = str(reference.get("version")) if reference and reference.get("version") else None - - if reference_name and reference_version: - # Fetch specific version - existing_agent = await self._project_client.agents.get_version( - agent_name=reference_name, agent_version=reference_version - ) - elif agent_name := (reference_name if reference_name else name): - # Fetch latest version - details = await self._project_client.agents.get(agent_name=agent_name) - existing_agent = details.versions.latest - else: - raise ValueError("Either name or reference must be provided to get an agent.") - - if not isinstance(existing_agent.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.") - - # Validate that required function tools are provided - self._validate_function_tools(existing_agent.definition.tools, tools) - - return self._to_chat_agent_from_details( - existing_agent, - normalize_tools(tools), - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def as_agent( - self, - details: AgentVersionDetails, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Wrap an SDK agent version object into a Agent without making HTTP calls. - - Use this when you already have an AgentVersionDetails from a previous API call. - - Args: - details: The AgentVersionDetails to wrap. - tools: Tools to make available to the agent. Required if the agent has function tools. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the agent version. - - Raises: - ValueError: If the agent definition is not a PromptAgentDefinition or required tools are missing. - """ - if not isinstance(details.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to create a Agent.") - - # Validate that required function tools are provided - self._validate_function_tools(details.definition.tools, tools) - - return self._to_chat_agent_from_details( - details, - normalize_tools(tools), - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def _to_chat_agent_from_details( - self, - details: AgentVersionDetails, - provided_tools: Sequence[ToolTypes] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a Agent from an AgentVersionDetails. - - Args: - details: The AgentVersionDetails containing the agent definition. - provided_tools: User-provided tools (including function implementations). - These are merged with hosted tools from the definition. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - """ - if not isinstance(details.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.") - - client = AzureAIClient( # pyright: ignore[reportDeprecated] - project_client=self._project_client, - agent_name=details.name, - agent_version=details.version, - agent_description=details.description, - model_deployment_name=details.definition.model, - ) - - # Merge tools: hosted tools from definition + user-provided function tools - # from_azure_ai_tools converts hosted tools (MCP, code interpreter, file search, web search) - # but function tools need the actual implementations from provided_tools - merged_tools = self._merge_tools(details.definition.tools, provided_tools) - merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} - merged_default_options.setdefault("model_id", details.definition.model) - - return Agent( # type: ignore[return-value] - client=client, - id=details.id, - name=details.name, - description=details.description, - instructions=details.definition.instructions, - tools=merged_tools, - default_options=cast(Any, merged_default_options), - middleware=middleware, - context_providers=context_providers, - ) - - def _merge_tools( - self, - definition_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolTypes] | None, - ) -> list[ToolTypes]: - """Merge hosted tools from definition with user-provided function tools. - - Args: - definition_tools: Tools from the agent definition (Azure AI format). - provided_tools: User-provided tools (Agent Framework format), including function implementations. - - Returns: - Combined list of tools for the Agent. - """ - merged: list[ToolTypes] = [] - - # Convert hosted tools from definition (MCP, code interpreter, file search, web search) - # Function tools from the definition are skipped - we use user-provided implementations instead - hosted_tools = from_azure_ai_tools(definition_tools) - for hosted_tool in hosted_tools: - # Skip function tool dicts - they don't have implementations - if isinstance(hosted_tool, dict) and hosted_tool.get("type") == "function": - continue - merged.append(hosted_tool) - - # Add user-provided function tools and MCP tools - if provided_tools: - for provided_tool in provided_tools: - # FunctionTool - has implementation for function calling - # MCPTool - Agent handles MCP connection and tool discovery at runtime - if isinstance(provided_tool, (FunctionTool, MCPTool)): - merged.append(provided_tool) # type: ignore[reportUnknownArgumentType] - - return merged - - def _validate_function_tools( - self, - agent_tools: Sequence[Any] | None, - provided_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, - ) -> None: - """Validate that required function tools are provided.""" - # Normalize and validate function tools - normalized_tools = normalize_tools(provided_tools) - tool_names = {tool.name for tool in normalized_tools if isinstance(tool, FunctionTool)} - - # If function tools exist in agent definition but were not provided, - # we need to raise an error, as it won't be possible to invoke the function. - missing_tools = [ - tool.name - for tool in (agent_tools or []) - if isinstance(tool, AzureFunctionTool) and tool.name not in tool_names - ] - - if missing_tools: - raise ValueError( - f"The following prompt agent definition required tools were not provided: {', '.join(missing_tools)}" - ) - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the provider and release resources. - - Only closes the underlying AIProjectClient if it was created by this provider. - """ - if self._should_close_client: - await self._project_client.close() diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py deleted file mode 100644 index 220640d270..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ /dev/null @@ -1,595 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -import sys -import warnings -from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any, cast - -from agent_framework import ( - Content, - FunctionTool, -) -from agent_framework.exceptions import IntegrationInvalidRequestException -from azure.ai.agents.models import ( - CodeInterpreterToolDefinition, - ToolDefinition, -) -from azure.ai.projects.models import ( - CodeInterpreterTool, - MCPTool, - TextResponseFormatJsonObject, - TextResponseFormatJsonSchema, - TextResponseFormatText, - Tool, - WebSearchPreviewTool, -) -from azure.ai.projects.models import ( - FileSearchTool as ProjectsFileSearchTool, -) -from azure.ai.projects.models import ( - FunctionTool as AzureFunctionTool, -) -from pydantic import BaseModel - -if sys.version_info >= (3, 11): - from typing import TypedDict # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -logger = logging.getLogger("agent_framework.azure") - - -class AzureAISettings(TypedDict, total=False): - """Azure AI Project settings. - - Settings are resolved in this order: explicit keyword arguments, values from an - explicitly provided .env file, then environment variables with the prefix - 'AZURE_AI_'. If settings are missing after resolution, validation will fail. - - Keyword Args: - project_endpoint: The Azure AI Project endpoint URL. - Can be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - model_deployment_name: The name of the model deployment to use. - Can be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - env_file_path: If provided, the .env settings are read from this file path location. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAISettings - - # Using environment variables - # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 - settings = AzureAISettings() - - # Or passing parameters directly - settings = AzureAISettings( - project_endpoint="https://your-project.cognitiveservices.azure.com", model_deployment_name="gpt-4" - ) - - # Or loading from a .env file - settings = AzureAISettings(env_file_path="path/to/.env") - """ - - project_endpoint: str | None - model_deployment_name: str | None - - -def _extract_project_connection_id(additional_properties: Mapping[str, Any] | None) -> str | None: - """Extract project_connection_id from tool additional_properties. - - Checks for both direct 'project_connection_id' key (programmatic usage) - and 'connection.name' structure (declarative/YAML usage). - - Args: - additional_properties: The additional_properties dict from a tool. - - Returns: - The project_connection_id if found, None otherwise. - """ - if not additional_properties: - return None - - # Check for direct project_connection_id (programmatic usage) - - if (proj_conn_id := additional_properties.get("project_connection_id")) and isinstance(proj_conn_id, str): - return proj_conn_id # type: ignore[no-any-return] - - # Check for connection.name structure (declarative/YAML usage) - if ( - (connection := additional_properties.get("connection")) - and isinstance(connection, Mapping) - and (name := connection.get("name")) # type: ignore - and isinstance(name, str) - ): - return name # type: ignore[no-any-return] - - return None - - -def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: - """Resolve a list of file ID values that may include Content objects. - - Accepts plain strings and Content objects with type "hosted_file", extracting - the file_id from each. This enables users to pass Content.from_hosted_file() - alongside plain file ID strings. - - Args: - file_ids: Sequence of file ID strings or Content objects, or None. - - Returns: - A list of resolved file ID strings, or None if input is None or empty. - - Raises: - ValueError: If a Content object has an unsupported type (not "hosted_file"). - """ - if not file_ids: - return None - - resolved: list[str] = [] - for item in file_ids: - if isinstance(item, str): - if not item: - raise ValueError("file_ids must not contain empty strings.") - resolved.append(item) - elif isinstance(item, Content): - if item.type != "hosted_file": - raise ValueError( - f"Unsupported Content type '{item.type}' for code interpreter file_ids. " - "Only Content.from_hosted_file() is supported." - ) - if item.file_id is None: - raise ValueError( - "Content.from_hosted_file() item is missing a file_id. " - "Ensure the Content object has a valid file_id before using it in file_ids." - ) - resolved.append(item.file_id) - - return resolved if resolved else None - - -def to_azure_ai_agent_tools( - tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, - run_options: dict[str, Any] | None = None, -) -> list[ToolDefinition | dict[str, Any]]: - """Convert Agent Framework tools to Azure AI V1 SDK tool definitions. - - .. deprecated:: - This function is deprecated and will be removed in a future release. - Use :func:`to_azure_ai_tools` instead for the V2 (Projects/Responses) API. - - Handles FunctionTool instances and dict-based tools from static factory methods. - - Args: - tools: Sequence of Agent Framework tools to convert. - run_options: Optional dict with run options. - - Returns: - List of Azure AI V1 SDK tool definitions. - - Raises: - ValueError: If tool configuration is invalid. - """ - warnings.warn( - "to_azure_ai_agent_tools() is deprecated and will be removed in a future release; " - "use to_azure_ai_tools() instead for the V2 (Projects/Responses) API.", - DeprecationWarning, - stacklevel=2, - ) - if not tools: - return [] - - tool_definitions: list[ToolDefinition | dict[str, Any]] = [] - for tool in tools: - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] - elif isinstance(tool, ToolDefinition): - # Pass through ToolDefinition subclasses unchanged (includes CodeInterpreterToolDefinition, etc.) - tool_definitions.append(tool) - elif hasattr(tool, "definitions") and not isinstance(tool, (dict, MutableMapping)): - # SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.) - tool_definitions.extend(tool.definitions) - # Handle tool resources (MCP resources handled separately) - if ( - run_options is not None - and hasattr(tool, "resources") - and tool.resources - and "mcp" not in tool.resources - ): - run_options.setdefault("tool_resources", {}) - if isinstance(tool.resources, Mapping): - run_options["tool_resources"].update(tool.resources) - elif isinstance(tool, (dict, MutableMapping)): - # Handle dict-based tools - pass through directly - tool_dict = tool if isinstance(tool, dict) else dict(tool) - tool_definitions.append(tool_dict) - else: - # Pass through other types unchanged - tool_definitions.append(tool) - return tool_definitions - - -def from_azure_ai_agent_tools( - tools: Sequence[ToolDefinition | dict[str, Any]] | None, -) -> list[dict[str, Any]]: - """Convert Azure AI V1 SDK tool definitions to dict-based tools. - - .. deprecated:: - This function is deprecated and will be removed in a future release. - Use :func:`from_azure_ai_tools` instead for the V2 (Projects/Responses) API. - - Args: - tools: Sequence of Azure AI V1 SDK tool definitions. - - Returns: - List of dict-based tool definitions. - """ - warnings.warn( - "from_azure_ai_agent_tools() is deprecated and will be removed in a future release; " - "use from_azure_ai_tools() instead for the V2 (Projects/Responses) API.", - DeprecationWarning, - stacklevel=2, - ) - if not tools: - return [] - - result: list[dict[str, Any]] = [] - for tool in tools: - # Handle SDK objects - if isinstance(tool, CodeInterpreterToolDefinition): - result.append({"type": "code_interpreter"}) - elif isinstance(tool, dict): - # Handle dict format - converted = _convert_dict_tool(tool) - if converted is not None: - result.append(converted) - elif hasattr(tool, "type"): - # Handle other SDK objects by type - converted = _convert_sdk_tool(tool) - if converted is not None: - result.append(converted) - return result - - -def _convert_dict_tool(tool: dict[str, Any]) -> dict[str, Any] | None: - """Convert a dict-format Azure AI tool to dict-based tool format.""" - tool_type = tool.get("type") - - if tool_type == "code_interpreter": - return {"type": "code_interpreter"} - - if tool_type == "file_search": - file_search_config = tool.get("file_search", {}) - vector_store_ids = file_search_config.get("vector_store_ids", []) - return {"type": "file_search", "vector_store_ids": vector_store_ids} - - if tool_type == "bing_grounding": - bing_config = tool.get("bing_grounding", {}) - connection_id = bing_config.get("connection_id") - return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None - - if tool_type == "bing_custom_search": - bing_config = tool.get("bing_custom_search", {}) - connection_id = bing_config.get("connection_id") - instance_name = bing_config.get("instance_name") - # Only return if both required fields are present - if connection_id and instance_name: - return { - "type": "bing_custom_search", - "connection_id": connection_id, - "instance_name": instance_name, - } - return None - - if tool_type == "mcp": - # MCP tools are defined on the Azure agent, no local handling needed - # Azure may not return full server_url, so skip conversion - return None - - if tool_type == "function": - # Function tools are returned as dicts - users must provide implementations - return tool - - # Unknown tool type - pass through - return tool - - -def _convert_sdk_tool(tool: ToolDefinition) -> dict[str, Any] | None: - """Convert an SDK-object Azure AI tool to dict-based tool format.""" - tool_type = getattr(tool, "type", None) - - if tool_type == "code_interpreter": - return {"type": "code_interpreter"} - - if tool_type == "file_search": - file_search_config = getattr(tool, "file_search", None) - vector_store_ids = getattr(file_search_config, "vector_store_ids", []) if file_search_config else [] - return {"type": "file_search", "vector_store_ids": vector_store_ids} - - if tool_type == "bing_grounding": - bing_config = getattr(tool, "bing_grounding", None) - connection_id = getattr(bing_config, "connection_id", None) if bing_config else None - return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None - - if tool_type == "bing_custom_search": - bing_config = getattr(tool, "bing_custom_search", None) - connection_id = getattr(bing_config, "connection_id", None) if bing_config else None - instance_name = getattr(bing_config, "instance_name", None) if bing_config else None - # Only return if both required fields are present - if connection_id and instance_name: - return { - "type": "bing_custom_search", - "connection_id": connection_id, - "instance_name": instance_name, - } - return None - - if tool_type == "mcp": - # MCP tools are defined on the Azure agent, no local handling needed - # Azure may not return full server_url, so skip conversion - return None - - if tool_type == "function": - # Function tools from SDK don't have implementations - skip - return None - - # Unknown tool type - convert to dict if possible - if hasattr(tool, "as_dict"): - return tool.as_dict() # type: ignore[union-attr] - return {"type": tool_type} if tool_type else {} - - -def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[dict[str, Any]]: - """Parses and converts a sequence of Azure AI tools into dict-based tools. - - Args: - tools: A sequence of tool objects or dictionaries - defining the tools to be parsed. Can be None. - - Returns: - list[dict[str, Any]]: A list of dict-based tool definitions. - """ - agent_tools: list[dict[str, Any]] = [] - if not tools: - return agent_tools - for tool in tools: - # Handle raw dictionary tools - tool_dict = tool if isinstance(tool, dict) else dict(tool) - tool_type = tool_dict.get("type") - - if tool_type == "mcp": - mcp_tool = cast(MCPTool, tool_dict) - result: dict[str, Any] = { - "type": "mcp", - "server_label": mcp_tool.get("server_label", ""), - "server_url": mcp_tool.get("server_url", ""), - } - if description := mcp_tool.get("server_description"): - result["server_description"] = description - if headers := mcp_tool.get("headers"): - result["headers"] = headers - if allowed_tools := mcp_tool.get("allowed_tools"): - result["allowed_tools"] = allowed_tools - if require_approval := mcp_tool.get("require_approval"): - result["require_approval"] = require_approval - if project_connection_id := mcp_tool.get("project_connection_id"): - result["project_connection_id"] = project_connection_id - agent_tools.append(result) - elif tool_type == "code_interpreter": - ci_tool = cast(CodeInterpreterTool, tool_dict) - container = ci_tool.get("container", {}) - result = {"type": "code_interpreter"} - if "file_ids" in container: - result["file_ids"] = container["file_ids"] - agent_tools.append(result) - elif tool_type == "file_search": - fs_tool = cast(ProjectsFileSearchTool, tool_dict) - result = {"type": "file_search"} - if "vector_store_ids" in fs_tool: - result["vector_store_ids"] = fs_tool["vector_store_ids"] - if max_results := fs_tool.get("max_num_results"): - result["max_num_results"] = max_results - agent_tools.append(result) - elif tool_type == "web_search_preview": - ws_tool = cast(WebSearchPreviewTool, tool_dict) - result = {"type": "web_search_preview"} - if user_location := ws_tool.get("user_location"): - result["user_location"] = { - "city": user_location.get("city"), - "country": user_location.get("country"), - "region": user_location.get("region"), - "timezone": user_location.get("timezone"), - } - agent_tools.append(result) - else: - agent_tools.append(tool_dict) - return agent_tools - - -def to_azure_ai_tools( - tools: Sequence[FunctionTool | MutableMapping[str, Any] | Tool] | None, -) -> list[Tool | dict[str, Any]]: - """Converts Agent Framework tools into Azure AI compatible tools. - - Handles FunctionTool instances and passes through SDK Tool types directly. - - Args: - tools: A sequence of Agent Framework tool objects, SDK Tool types, or dictionaries - defining the tools to be converted. Can be None. - - Returns: - list[Tool | dict[str, Any]]: A list of converted tools compatible with Azure AI. - """ - azure_tools: list[Tool | dict[str, Any]] = [] - if not tools: - return azure_tools - - for tool in tools: - if isinstance(tool, FunctionTool): - params = tool.parameters() - params["additionalProperties"] = False - azure_tools.append( - AzureFunctionTool( - name=tool.name, - parameters=params, - strict=False, - description=tool.description, - ) - ) - elif isinstance(tool, Tool): - # Pass through SDK Tool types directly (CodeInterpreterTool, FileSearchTool, etc.) - azure_tools.append(tool) - elif isinstance(tool, MutableMapping): - # Convert mutable mappings into plain dicts for stable typing. - tool_dict: dict[str, Any] = dict(tool) - if tool_dict.get("type") == "mcp": - azure_tools.append(_prepare_mcp_tool_dict_for_azure_ai(tool_dict)) - else: - azure_tools.append(tool_dict) - else: - # Pass through any other supported tool objects unchanged. - azure_tools.append(tool) - - return azure_tools - - -def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool: - """Convert dict-based MCP tool to Azure AI MCPTool format. - - Args: - tool_dict: The dict-based MCP tool configuration. - - Returns: - MCPTool: The converted Azure AI MCPTool. - """ - server_label = tool_dict.get("server_label", "") - server_url = tool_dict.get("server_url", "") - mcp: MCPTool = MCPTool(server_label=server_label, server_url=server_url) - - if description := tool_dict.get("server_description"): - mcp["server_description"] = description - - # Check for project_connection_id - project_connection_id = tool_dict.get("project_connection_id") - if not isinstance(project_connection_id, str): - additional_properties = tool_dict.get("additional_properties") - project_connection_id = ( - _extract_project_connection_id(additional_properties) # pyright: ignore[reportUnknownArgumentType] - if isinstance(additional_properties, Mapping) - else None - ) - - if project_connection_id: - mcp["project_connection_id"] = project_connection_id - elif headers := tool_dict.get("headers"): - mcp["headers"] = headers - - if allowed_tools := tool_dict.get("allowed_tools"): - mcp["allowed_tools"] = list(allowed_tools) - - if require_approval := tool_dict.get("require_approval"): - mcp["require_approval"] = require_approval - - return mcp - - -def create_text_format_config( - response_format: type[BaseModel] | Mapping[str, Any], -) -> TextResponseFormatJsonSchema | TextResponseFormatJsonObject | TextResponseFormatText: - """Convert response_format into Azure text format configuration.""" - if isinstance(response_format, type) and issubclass(response_format, BaseModel): - schema = response_format.model_json_schema() - # Ensure additionalProperties is explicitly false to satisfy Azure validation - if isinstance(schema, dict): - schema.setdefault("additionalProperties", False) - return TextResponseFormatJsonSchema( - name=response_format.__name__, - schema=schema, - strict=True, - ) - - if isinstance(response_format, Mapping): - format_config = _convert_response_format(response_format) - format_type = format_config.get("type") - if format_type == "json_schema": - # Ensure schema includes additionalProperties=False to satisfy Azure validation - schema = dict(format_config.get("schema", {})) # type: ignore[assignment] - schema.setdefault("additionalProperties", False) - config_kwargs: dict[str, Any] = { - "name": format_config.get("name") or "response", - "schema": schema, - } - if "strict" in format_config: - config_kwargs["strict"] = format_config["strict"] - if "description" in format_config: - config_kwargs["description"] = format_config["description"] - return TextResponseFormatJsonSchema(**config_kwargs) - if format_type == "json_object": - return TextResponseFormatJsonObject() - if format_type == "text": - return TextResponseFormatText() - - raise IntegrationInvalidRequestException("response_format must be a Pydantic model or mapping.") - - -def _convert_response_format(response_format: Mapping[str, Any]) -> dict[str, Any]: - """Convert Chat style response_format into Responses text format config.""" - if "format" in response_format and isinstance(response_format["format"], Mapping): - return dict(cast("Mapping[str, Any]", response_format["format"])) - - format_type = response_format.get("type") - if format_type == "json_schema": - schema_section = response_format.get("json_schema", response_format) - if not isinstance(schema_section, Mapping): - raise IntegrationInvalidRequestException("json_schema response_format must be a mapping.") - schema_section_typed = cast("Mapping[str, Any]", schema_section) - schema: Any = schema_section_typed.get("schema") - if schema is None: - raise IntegrationInvalidRequestException("json_schema response_format requires a schema.") - name: str = str( - schema_section_typed.get("name") - or schema_section_typed.get("title") - or (cast("Mapping[str, Any]", schema).get("title") if isinstance(schema, Mapping) else None) - or "response" - ) - format_config: dict[str, Any] = { - "type": "json_schema", - "name": name, - "schema": schema, - } - if "strict" in schema_section: - format_config["strict"] = schema_section["strict"] - if "description" in schema_section and schema_section["description"] is not None: - format_config["description"] = schema_section["description"] - return format_config - - if format_type in {"json_object", "text"}: - return {"type": format_type} - - # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) - # by wrapping them in the expected json_schema envelope. - # Detect by checking for JSON Schema primitive types or known schema keywords. - json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} - json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} - if format_type in json_schema_primitive_types or ( - format_type is None and any(k in response_format for k in json_schema_keywords) - ): - schema = dict(response_format) - if schema.get("type") == "object" and "additionalProperties" not in schema: - schema["additionalProperties"] = False - # Pop title from schema since OpenAI strict mode rejects unknown keys; - # use it as the schema name in the envelope instead. - name = str(schema.pop("title", None) or "response") - return { - "type": "json_schema", - "name": name, - "schema": schema, - "strict": True, - } - - raise IntegrationInvalidRequestException("Unsupported response_format provided for Azure AI client.") diff --git a/python/packages/azure-ai/agent_framework_azure_ai/py.typed b/python/packages/azure-ai/agent_framework_azure_ai/py.typed deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml deleted file mode 100644 index 0a4e0cb66e..0000000000 --- a/python/packages/azure-ai/pyproject.toml +++ /dev/null @@ -1,109 +0,0 @@ -[project] -name = "agent-framework-azure-ai" -description = "Azure AI Foundry integration for Microsoft Agent Framework." -authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] -readme = "README.md" -requires-python = ">=3.10" -version = "1.0.0rc6" -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", - "Programming Language :: Python :: 3.14", - "Typing :: Typed", -] -dependencies = [ - "agent-framework-core>=1.0.0rc6", - "agent-framework-openai>=1.0.0rc6", - "azure-ai-projects>=2.0.0,<3.0", - "azure-ai-agents>=1.2.0b5,<1.2.0b6", - "azure-ai-inference>=1.0.0b9,<1.0.0b10", - "azure-identity>=1,<2", - "aiohttp>=3.7.0,<4", -] - -[tool.uv] -prerelease = "if-necessary-or-explicit" -environments = [ - "sys_platform == 'darwin'", - "sys_platform == 'linux'", - "sys_platform == 'win32'" -] - -[tool.uv-dynamic-versioning] -fallback-version = "0.0.0" - -[tool.pytest.ini_options] -testpaths = 'tests' -addopts = "-ra -q -r fEX" -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" -filterwarnings = [] -timeout = 120 -markers = [ - "integration: marks tests as integration tests that require external services", -] - -[tool.ruff] -extend = "../../pyproject.toml" - -[tool.coverage.run] -omit = [ - "**/__init__.py" -] - -[tool.pyright] -extends = "../../pyproject.toml" -include = ["agent_framework_azure_ai"] - -[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_azure_ai"] -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_azure_ai" - -[tool.poe.tasks.test] -help = "Run the default unit test suite for this package." -cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests' - -[tool.poe.tasks.integration-tests] -help = "Run the package integration test suite." -cmd = """ -pytest --import-mode=importlib --n logical --dist worksteal -tests -""" - -[build-system] -requires = ["flit-core >= 3.11,<4.0"] -build-backend = "flit_core.buildapi" diff --git a/python/packages/azure-ai/tests/assets/sample_image.jpg b/python/packages/azure-ai/tests/assets/sample_image.jpg deleted file mode 100644 index ea6486656f..0000000000 Binary files a/python/packages/azure-ai/tests/assets/sample_image.jpg and /dev/null differ diff --git a/python/packages/azure-ai/tests/azure_openai/conftest.py b/python/packages/azure-ai/tests/azure_openai/conftest.py deleted file mode 100644 index 8e32c53608..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from typing import Any - -from agent_framework import Message -from pytest import fixture - - -# region: Connector Settings fixtures -@fixture -def exclude_list(request: Any) -> list[str]: - """Fixture that returns a list of environment variables to exclude.""" - return request.param if hasattr(request, "param") else [] - - -@fixture -def override_env_param_dict(request: Any) -> dict[str, str]: - """Fixture that returns a dict of environment variables to override.""" - return request.param if hasattr(request, "param") else {} - - -# These two fixtures are used for multiple things, also non-connector tests -@fixture() -def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore - """Fixture to set environment variables for AzureOpenAISettings.""" - - if exclude_list is None: - exclude_list = [] - - if override_env_param_dict is None: - override_env_param_dict = {} - - env_vars = { - "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.com", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_chat_deployment", - "AZURE_OPENAI_TEXT_DEPLOYMENT_NAME": "test_text_deployment", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment", - "AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME": "test_text_to_image_deployment", - "AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME": "test_audio_to_text_deployment", - "AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME": "test_text_to_audio_deployment", - "AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME": "test_realtime_deployment", - "AZURE_OPENAI_API_KEY": "test_api_key", - "AZURE_OPENAI_API_VERSION": "2023-03-15-preview", - "AZURE_OPENAI_BASE_URL": "https://test_text_deployment.test-base-url.com", - "AZURE_OPENAI_TOKEN_ENDPOINT": "https://test-token-endpoint.com", - } - - env_vars.update(override_env_param_dict) # type: ignore - - for key, value in env_vars.items(): - if key in exclude_list: - monkeypatch.delenv(key, raising=False) # type: ignore - continue - monkeypatch.setenv(key, value) # type: ignore - - return env_vars - - -@fixture(scope="function") -def chat_history() -> list[Message]: - return [] diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py deleted file mode 100644 index dd0e2f2d38..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py +++ /dev/null @@ -1,409 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Annotated -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - SupportsChatGetResponse, - tool, -) -from agent_framework._settings import SecretString -from agent_framework.azure import AzureOpenAIAssistantsClient -from pydantic import Field - - -def create_test_azure_assistants_client( - mock_async_azure_openai: MagicMock, - deployment_name: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - thread_id: str | None = None, - should_delete_assistant: bool = False, -) -> AzureOpenAIAssistantsClient: - """Helper function to create AzureOpenAIAssistantsClient instances for testing.""" - client = AzureOpenAIAssistantsClient( - deployment_name=deployment_name or "test_chat_deployment", - assistant_id=assistant_id, - assistant_name=assistant_name, - thread_id=thread_id, - api_key="test-api-key", - endpoint="https://test-endpoint.com", - async_client=mock_async_azure_openai, - ) - # Set the _should_delete_assistant flag directly if needed - if should_delete_assistant: - object.__setattr__(client, "_should_delete_assistant", True) - return client - - -@pytest.fixture -def mock_async_azure_openai() -> MagicMock: - """Mock AsyncAzureOpenAI client.""" - mock_client = MagicMock() - - # Mock beta.assistants - mock_client.beta.assistants.create = AsyncMock(return_value=MagicMock(id="test-assistant-id")) - mock_client.beta.assistants.delete = AsyncMock() - - # Mock beta.threads - mock_client.beta.threads.create = AsyncMock(return_value=MagicMock(id="test-thread-id")) - mock_client.beta.threads.delete = AsyncMock() - - # Mock beta.threads.runs - mock_client.beta.threads.runs.create = AsyncMock(return_value=MagicMock(id="test-run-id")) - mock_client.beta.threads.runs.retrieve = AsyncMock() - mock_client.beta.threads.runs.submit_tool_outputs = AsyncMock() - - # Mock beta.threads.messages - mock_client.beta.threads.messages.create = AsyncMock() - mock_client.beta.threads.messages.list = AsyncMock(return_value=MagicMock(data=[])) - - return mock_client - - -def test_azure_assistants_client_init_with_client(mock_async_azure_openai: MagicMock) -> None: - """Test AzureOpenAIAssistantsClient initialization with existing client.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, - deployment_name="test_chat_deployment", - assistant_id="existing-assistant-id", - thread_id="test-thread-id", - ) - - assert client.client is mock_async_azure_openai - assert client.model == "test_chat_deployment" - assert client.assistant_id == "existing-assistant-id" - assert client.thread_id == "test-thread-id" - assert not client._should_delete_assistant # type: ignore - assert isinstance(client, SupportsChatGetResponse) - - -def test_azure_assistants_client_init_auto_create_client( - azure_openai_unit_test_env: dict[str, str], - mock_async_azure_openai: MagicMock, -) -> None: - """Test AzureOpenAIAssistantsClient initialization with auto-created client.""" - client = AzureOpenAIAssistantsClient( - deployment_name=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - assistant_name="TestAssistant", - api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - async_client=mock_async_azure_openai, - ) - - assert client.client is mock_async_azure_openai - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert client.assistant_id is None - assert client.assistant_name == "TestAssistant" - assert not client._should_delete_assistant # type: ignore - - -def test_azure_assistants_client_init_validation_fail() -> None: - """Test AzureOpenAIAssistantsClient initialization with validation failure.""" - with pytest.raises(ValueError): - # Force failure by providing invalid deployment name type - this should cause validation to fail - AzureOpenAIAssistantsClient(deployment_name=123, api_key="valid-key") # type: ignore - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True) -def test_azure_assistants_client_init_missing_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test AzureOpenAIAssistantsClient initialization with missing deployment name.""" - with pytest.raises(ValueError): - AzureOpenAIAssistantsClient(api_key=azure_openai_unit_test_env.get("AZURE_OPENAI_API_KEY", "test-key")) - - -def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test AzureOpenAIAssistantsClient initialization with default headers.""" - default_headers = {"X-Unit-Test": "test-guid"} - - client = AzureOpenAIAssistantsClient( - deployment_name="test_chat_deployment", - api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - default_headers=default_headers, - ) - - assert client.model == "test_chat_deployment" - assert isinstance(client, SupportsChatGetResponse) - - # Assert that the default header we added is present in the client's default headers - for key, value in default_headers.items(): - assert key in client.client.default_headers - assert client.client.default_headers[key] == value - - -async def test_azure_assistants_client_get_assistant_id_or_create_existing_assistant( - mock_async_azure_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when assistant_id is already provided.""" - client = create_test_azure_assistants_client(mock_async_azure_openai, assistant_id="existing-assistant-id") - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "existing-assistant-id" - assert not client._should_delete_assistant # type: ignore - mock_async_azure_openai.beta.assistants.create.assert_not_called() - - -async def test_azure_assistants_client_get_assistant_id_or_create_create_new( - mock_async_azure_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when creating a new assistant.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, deployment_name="test_chat_deployment", assistant_name="TestAssistant" - ) - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "test-assistant-id" - assert client._should_delete_assistant # type: ignore - mock_async_azure_openai.beta.assistants.create.assert_called_once() - - -async def test_azure_assistants_client_aclose_should_not_delete( - mock_async_azure_openai: MagicMock, -) -> None: - """Test close when assistant should not be deleted.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, assistant_id="assistant-to-keep", should_delete_assistant=False - ) - - await client.close() # type: ignore - - # Verify assistant deletion was not called - mock_async_azure_openai.beta.assistants.delete.assert_not_called() - assert not client._should_delete_assistant # type: ignore - - -async def test_azure_assistants_client_aclose_should_delete(mock_async_azure_openai: MagicMock) -> None: - """Test close method calls cleanup.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - await client.close() - - # Verify assistant deletion was called - mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - assert not client._should_delete_assistant # type: ignore - - -async def test_azure_assistants_client_async_context_manager(mock_async_azure_openai: MagicMock) -> None: - """Test async context manager functionality.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - # Test context manager - async with client: - pass # Just test that we can enter and exit - - # Verify cleanup was called on exit - mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - - -def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test serialization of AzureOpenAIAssistantsClient.""" - default_headers = {"X-Unit-Test": "test-guid"} - - # Test basic initialization and to_dict - client = AzureOpenAIAssistantsClient( - deployment_name="test_chat_deployment", - assistant_id="test-assistant-id", - assistant_name="TestAssistant", - thread_id="test-thread-id", - api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - default_headers=default_headers, - ) - - dumped_settings = client.to_dict() - - assert dumped_settings["model"] == "test_chat_deployment" - assert dumped_settings["assistant_id"] == "test-assistant-id" - assert dumped_settings["assistant_name"] == "TestAssistant" - assert dumped_settings["thread_id"] == "test-thread-id" - - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - # Assert that the 'User-Agent' header is not present in the dumped_settings default headers - assert "User-Agent" not in dumped_settings["default_headers"] - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -def test_azure_assistants_client_entra_id_authentication() -> None: - """Test credential authentication path with sync credential.""" - mock_credential = MagicMock() - mock_provider = MagicMock(return_value="token-string") - - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch( - "agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider", - return_value=mock_provider, - ) as mock_resolve, - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": None, - "token_endpoint": "https://cognitiveservices.azure.com/.default", - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - endpoint="https://test-endpoint.openai.azure.com", - credential=mock_credential, - token_endpoint="https://cognitiveservices.azure.com/.default", - ) - - # Verify credential was resolved to a token provider - mock_resolve.assert_called_once_with(mock_credential, "https://cognitiveservices.azure.com/.default") - - # Verify client was created with the token provider - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["azure_ad_token_provider"] is mock_provider - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) - - -def test_azure_assistants_client_no_authentication_error() -> None: - """Test authentication validation error when no auth provided.""" - with patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": None, - "token_endpoint": None, - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - # Test missing authentication raises error - with pytest.raises(ValueError, match="api_key, credential, or a client"): - AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - endpoint="https://test-endpoint.openai.azure.com", - # No authentication provided at all - ) - - -def test_azure_assistants_client_callable_credential() -> None: - """Test callable token provider as credential.""" - mock_provider = MagicMock(return_value="my-token") - - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch( - "agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider", - return_value=mock_provider, - ), - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": None, - "token_endpoint": "https://cognitiveservices.azure.com/.default", - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - endpoint="https://test-endpoint.openai.azure.com", - credential=mock_provider, - token_endpoint="https://cognitiveservices.azure.com/.default", - ) - - # Verify client was created with the token provider - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["azure_ad_token_provider"] is mock_provider - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) - - -def test_azure_assistants_client_base_url_configuration() -> None: - """Test base_url client parameter path.""" - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": SecretString("test-api-key"), - "token_endpoint": None, - "api_version": "2024-05-01-preview", - "endpoint": None, - "base_url": "https://custom-base-url.com", - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", api_key="test-api-key", base_url="https://custom-base-url.com" - ) - - # base_url path - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["base_url"] == "https://custom-base-url.com" - assert "azure_endpoint" not in call_args - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) - - -def test_azure_assistants_client_azure_endpoint_configuration() -> None: - """Test azure_endpoint client parameter path.""" - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": SecretString("test-api-key"), - "token_endpoint": None, - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - api_key="test-api-key", - endpoint="https://test-endpoint.openai.azure.com", - ) - - # azure_endpoint path - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["azure_endpoint"] == "https://test-endpoint.openai.azure.com" - assert "base_url" not in call_args - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py deleted file mode 100644 index c1485d430d..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py +++ /dev/null @@ -1,1102 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -import os -from functools import wraps -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import openai -import pytest -from agent_framework import ( - Agent, - AgentResponse, - AgentResponseUpdate, - ChatResponse, - ChatResponseUpdate, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework._telemetry import USER_AGENT_KEY -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.exceptions import ChatClientException -from agent_framework_openai import ( - ContentFilterResultSeverity, - OpenAIContentFilterException, -) -from azure.identity import AzureCliCredential -from httpx import Request, Response -from openai import AsyncAzureOpenAI, AsyncStream -from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions -from openai.types.chat import ChatCompletion, ChatCompletionChunk -from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice -from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta -from openai.types.chat.chat_completion_message import ChatCompletionMessage - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIChatClient is deprecated\\..*:DeprecationWarning") - -# region Service Setup - -skip_if_azure_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), - reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", -) - - -def _with_azure_openai_debug() -> Any: - def decorator(func: Any) -> Any: - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - try: - return await func(*args, **kwargs) - except Exception as exc: - model = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") - debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" - if hasattr(exc, "add_note"): - exc.add_note(debug_message) - elif exc.args: - exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) - else: - exc.args = (debug_message,) - raise - - return wrapper - - return decorator - - -def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization - azure_chat_client = AzureOpenAIChatClient() - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - -def test_init_client(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization with client - client = MagicMock(spec=AsyncAzureOpenAI) - azure_chat_client = AzureOpenAIChatClient(async_client=client) - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - - -def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None: - # Custom header for testing - default_headers = {"X-Unit-Test": "test-guid"} - - azure_chat_client = AzureOpenAIChatClient( - default_headers=default_headers, - ) - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, SupportsChatGetResponse) - for key, value in default_headers.items(): - assert key in azure_chat_client.client.default_headers - assert azure_chat_client.client.default_headers[key] == value - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True) -def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - azure_chat_client = AzureOpenAIChatClient() - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True) -def test_init_with_empty_deployment_name( - azure_openai_unit_test_env: dict[str, str], -) -> None: - with pytest.raises(ValueError): - AzureOpenAIChatClient() - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True) -def test_init_with_empty_endpoint_and_base_url( - azure_openai_unit_test_env: dict[str, str], -) -> None: - with pytest.raises(ValueError): - AzureOpenAIChatClient() - - -@pytest.mark.parametrize( - "override_env_param_dict", - [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], - indirect=True, -) -def test_init_with_invalid_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - # Note: URL scheme validation was previously handled by pydantic's HTTPsUrl type. - # After migrating to load_settings with TypedDict, endpoint is a plain string and no longer - # validated at the settings level. The Azure OpenAI SDK may reject invalid URLs at runtime. - client = AzureOpenAIChatClient() - assert client is not None - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True) -def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: - default_headers = {"X-Test": "test"} - - settings = { - "deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - "endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - "api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - "api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"], - "default_headers": default_headers, - } - - azure_chat_client = AzureOpenAIChatClient.from_dict(settings) - dumped_settings = azure_chat_client.to_dict() - assert dumped_settings["model"] == settings["deployment_name"] - assert str(settings["endpoint"]) in str(dumped_settings["endpoint"]) - assert str(settings["deployment_name"]) == str(dumped_settings["deployment_name"]) - assert settings["api_version"] == dumped_settings["api_version"] - assert "api_key" not in dumped_settings - - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - - # Assert that the 'User-agent' header is not present in the dumped_settings default headers - assert USER_AGENT_KEY not in dumped_settings["default_headers"] - - -# endregion -# region CMC - - -@pytest.fixture -def mock_chat_completion_response() -> ChatCompletion: - return ChatCompletion( - id="test_id", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage(content="test", role="assistant"), - finish_reason="stop", - ) - ], - created=0, - model="test", - object="chat.completion", - ) - - -@pytest.fixture -def mock_streaming_chat_completion_response() -> AsyncStream[ChatCompletionChunk]: - content = ChatCompletionChunk( - id="test_id", - choices=[ - ChunkChoice( - index=0, - delta=ChunkChoiceDelta(content="test", role="assistant"), - finish_reason="stop", - ) - ], - created=0, - model="test", - object="chat.completion.chunk", - ) - stream = MagicMock(spec=AsyncStream) - stream.__aiter__.return_value = [content] - return stream - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - await azure_chat_client.get_response( - messages=chat_history, - ) - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - stream=False, - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_with_logit_bias( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - chat_history.append(Message(text=prompt, role="user")) - - token_bias: dict[str | int, float] = {"1": -100} - - azure_chat_client = AzureOpenAIChatClient() - - await azure_chat_client.get_response(messages=chat_history, options={"logit_bias": token_bias}) - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - stream=False, - logit_bias=token_bias, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_with_stop( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - chat_history.append(Message(text=prompt, role="user")) - - stop = ["!"] - - azure_chat_client = AzureOpenAIChatClient() - - await azure_chat_client.get_response(messages=chat_history, options={"stop": stop}) - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - stream=False, - stop=stop, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_azure_on_your_data( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_chat_completion_response.choices = [ - Choice( - index=0, - message=ChatCompletionMessage( - content="test", - role="assistant", - context={ # type: ignore - "citations": [ - { - "content": "test content", - "title": "test title", - "url": "test url", - "filepath": "test filepath", - "chunk_id": "test chunk_id", - } - ], - "intent": "query used", - }, - ), - finish_reason="stop", - ) - ] - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - messages_in = chat_history - chat_history.append(Message(text=prompt, role="user")) - messages_out: list[Message] = [] - messages_out.append(Message(text=prompt, role="user")) - - expected_data_settings = { - "data_sources": [ - { - "type": "AzureCognitiveSearch", - "parameters": { - "indexName": "test_index", - "endpoint": "https://test-endpoint-search.com", - "key": "test_key", - }, - } - ] - } - - azure_chat_client = AzureOpenAIChatClient() - - content = await azure_chat_client.get_response( - messages=messages_in, - options={"extra_body": expected_data_settings}, - ) - assert len(content.messages) == 1 - assert len(content.messages[0].contents) == 1 - assert content.messages[0].contents[0].type == "text" - assert len(content.messages[0].contents[0].annotations) == 1 - assert content.messages[0].contents[0].annotations[0]["title"] == "test title" - assert content.messages[0].contents[0].text == "test" - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore - stream=False, - extra_body=expected_data_settings, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_azure_on_your_data_string( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_chat_completion_response.choices = [ - Choice( - index=0, - message=ChatCompletionMessage( - content="test", - role="assistant", - context=json.dumps({ # type: ignore - "citations": [ - { - "content": "test content", - "title": "test title", - "url": "test url", - "filepath": "test filepath", - "chunk_id": "test chunk_id", - } - ], - "intent": "query used", - }), - ), - finish_reason="stop", - ) - ] - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - messages_in = chat_history - messages_in.append(Message(text=prompt, role="user")) - messages_out: list[Message] = [] - messages_out.append(Message(text=prompt, role="user")) - - expected_data_settings = { - "data_sources": [ - { - "type": "AzureCognitiveSearch", - "parameters": { - "indexName": "test_index", - "endpoint": "https://test-endpoint-search.com", - "key": "test_key", - }, - } - ] - } - - azure_chat_client = AzureOpenAIChatClient() - - content = await azure_chat_client.get_response( - messages=messages_in, - options={"extra_body": expected_data_settings}, - ) - assert len(content.messages) == 1 - assert len(content.messages[0].contents) == 1 - assert content.messages[0].contents[0].type == "text" - assert len(content.messages[0].contents[0].annotations) == 1 - assert content.messages[0].contents[0].annotations[0]["title"] == "test title" - assert content.messages[0].contents[0].text == "test" - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore - stream=False, - extra_body=expected_data_settings, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_azure_on_your_data_fail( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_chat_completion_response.choices = [ - Choice( - index=0, - message=ChatCompletionMessage( - content="test", - role="assistant", - context="not a dictionary", # type: ignore - ), - finish_reason="stop", - ) - ] - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - messages_in = chat_history - messages_in.append(Message(text=prompt, role="user")) - messages_out: list[Message] = [] - messages_out.append(Message(text=prompt, role="user")) - - expected_data_settings = { - "data_sources": [ - { - "type": "AzureCognitiveSearch", - "parameters": { - "indexName": "test_index", - "endpoint": "https://test-endpoint-search.com", - "key": "test_key", - }, - } - ] - } - - azure_chat_client = AzureOpenAIChatClient() - - content = await azure_chat_client.get_response( - messages=messages_in, - options={"extra_body": expected_data_settings}, - ) - assert len(content.messages) == 1 - assert len(content.messages[0].contents) == 1 - assert content.messages[0].contents[0].type == "text" - assert content.messages[0].contents[0].text == "test" - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore - stream=False, - extra_body=expected_data_settings, - ) - - -CONTENT_FILTERED_ERROR_MESSAGE = ( - "The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please " - "modify your prompt and retry. To learn more about our content filtering policies please read our " - "documentation: https://go.microsoft.com/fwlink/?linkid=2198766" -) -CONTENT_FILTERED_ERROR_FULL_MESSAGE = ( - "Error code: 400 - {'error': {'message': \"%s\", 'type': null, 'param': 'prompt', 'code': 'content_filter', " - "'status': 400, 'innererror': {'code': 'ResponsibleAIPolicyViolation', 'content_filter_result': {'hate': " - "{'filtered': True, 'severity': 'high'}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': " - "{'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}}}" -) % CONTENT_FILTERED_ERROR_MESSAGE - - -@patch.object(AsyncChatCompletions, "create") -async def test_content_filtering_raises_correct_exception( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - prompt = "some prompt that would trigger the content filtering" - chat_history.append(Message(text=prompt, role="user")) - - test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - assert test_endpoint is not None - mock_create.side_effect = openai.BadRequestError( - CONTENT_FILTERED_ERROR_FULL_MESSAGE, - response=Response(400, request=Request("POST", test_endpoint)), - body={ - "message": CONTENT_FILTERED_ERROR_MESSAGE, - "type": None, - "param": "prompt", - "code": "content_filter", - "status": 400, - "innererror": { - "code": "ResponsibleAIPolicyViolation", - "content_filter_result": { - "hate": {"filtered": True, "severity": "high"}, - "self_harm": {"filtered": False, "severity": "safe"}, - "sexual": {"filtered": False, "severity": "safe"}, - "violence": {"filtered": False, "severity": "safe"}, - }, - }, - }, - ) - - azure_chat_client = AzureOpenAIChatClient() - - with pytest.raises(OpenAIContentFilterException, match="service encountered a content error") as exc_info: - await azure_chat_client.get_response( - messages=chat_history, - ) - - content_filter_exc = exc_info.value - assert content_filter_exc.param == "prompt" - assert content_filter_exc.content_filter_result["hate"].filtered - assert content_filter_exc.content_filter_result["hate"].severity == ContentFilterResultSeverity.HIGH - - -@patch.object(AsyncChatCompletions, "create") -async def test_content_filtering_without_response_code_raises_with_default_code( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - prompt = "some prompt that would trigger the content filtering" - chat_history.append(Message(text=prompt, role="user")) - - test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - assert test_endpoint is not None - mock_create.side_effect = openai.BadRequestError( - CONTENT_FILTERED_ERROR_FULL_MESSAGE, - response=Response(400, request=Request("POST", test_endpoint)), - body={ - "message": CONTENT_FILTERED_ERROR_MESSAGE, - "type": None, - "param": "prompt", - "code": "content_filter", - "status": 400, - "innererror": { - "content_filter_result": { - "hate": {"filtered": True, "severity": "high"}, - "self_harm": {"filtered": False, "severity": "safe"}, - "sexual": {"filtered": False, "severity": "safe"}, - "violence": {"filtered": False, "severity": "safe"}, - }, - }, - }, - ) - - azure_chat_client = AzureOpenAIChatClient() - - with pytest.raises(OpenAIContentFilterException, match="service encountered a content error"): - await azure_chat_client.get_response( - messages=chat_history, - ) - - -@patch.object(AsyncChatCompletions, "create") -async def test_bad_request_non_content_filter( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - prompt = "some prompt that would trigger the content filtering" - chat_history.append(Message(text=prompt, role="user")) - - test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - assert test_endpoint is not None - mock_create.side_effect = openai.BadRequestError( - "The request was bad.", - response=Response(400, request=Request("POST", test_endpoint)), - body={}, - ) - - azure_chat_client = AzureOpenAIChatClient() - - with pytest.raises(ChatClientException, match="service failed to complete the prompt"): - await azure_chat_client.get_response( - messages=chat_history, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_get_streaming( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk], -) -> None: - mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - async for msg in azure_chat_client.get_response( - messages=chat_history, - stream=True, - ): - assert msg is not None - assert msg.message_id is not None - assert msg.response_id is not None - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - stream=True, - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - # NOTE: The `stream_options={"include_usage": True}` is explicitly enforced in - # `OpenAIChatCompletionBase.get_response(..., stream=True)`. - # To ensure consistency, we align the arguments here accordingly. - stream_options={"include_usage": True}, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_streaming_with_none_delta( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - """Test streaming handles None delta from async content filtering.""" - # First chunk has None delta (simulates async filtering) - chunk_choice_with_none = ChunkChoice.model_construct(index=0, delta=None, finish_reason=None) - chunk_with_none_delta = ChatCompletionChunk.model_construct( - id="test_id", - choices=[chunk_choice_with_none], - created=0, - model="test", - object="chat.completion.chunk", - ) - # Second chunk has actual content - chunk_with_content = ChatCompletionChunk( - id="test_id", - choices=[ - ChunkChoice( - index=0, - delta=ChunkChoiceDelta(content="test", role="assistant"), - finish_reason="stop", - ) - ], - created=0, - model="test", - object="chat.completion.chunk", - ) - stream = MagicMock(spec=AsyncStream) - stream.__aiter__.return_value = [chunk_with_none_delta, chunk_with_content] - mock_create.return_value = stream - - chat_history.append(Message(text="hello world", role="user")) - azure_chat_client = AzureOpenAIChatClient() - - results: list[ChatResponseUpdate] = [] - async for msg in azure_chat_client.get_response(messages=chat_history, stream=True): - results.append(msg) - - assert len(results) > 0 - assert any(content.type == "text" and content.text == "test" for msg in results for content in msg.contents) - assert any(msg.contents for msg in results) - - -# region _parse_text_from_openai direct unit tests - - -def test_parse_text_from_openai_with_choice_message(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai correctly reads message from a Choice.""" - client = AzureOpenAIChatClient() - choice = Choice( - index=0, - message=ChatCompletionMessage(content="hello", role="assistant"), - finish_reason="stop", - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "hello" - - -def test_parse_text_from_openai_with_chunk_choice_delta(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai correctly reads delta from a ChunkChoice.""" - client = AzureOpenAIChatClient() - choice = ChunkChoice( - index=0, - delta=ChunkChoiceDelta(content="streamed", role="assistant"), - finish_reason=None, - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "streamed" - - -def test_parse_text_from_openai_refusal_choice(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns refusal text from a Choice.""" - client = AzureOpenAIChatClient() - choice = Choice( - index=0, - message=ChatCompletionMessage(content=None, role="assistant", refusal="I cannot help with that"), - finish_reason="stop", - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "I cannot help with that" - - -def test_parse_text_from_openai_refusal_chunk_choice(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns refusal text from a ChunkChoice.""" - client = AzureOpenAIChatClient() - choice = ChunkChoice( - index=0, - delta=ChunkChoiceDelta(content=None, role="assistant", refusal="I cannot help with that"), - finish_reason=None, - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "I cannot help with that" - - -def test_parse_text_from_openai_no_content_no_refusal(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns None when no content or refusal.""" - client = AzureOpenAIChatClient() - choice = Choice( - index=0, - message=ChatCompletionMessage(content=None, role="assistant"), - finish_reason="stop", - ) - result = client._parse_text_from_openai(choice) - assert result is None - - -def test_parse_text_from_openai_none_delta(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns None when delta is None (async content filtering).""" - client = AzureOpenAIChatClient() - choice = ChunkChoice.model_construct(index=0, delta=None, finish_reason=None) - result = client._parse_text_from_openai(choice) - assert result is None - - -# endregion - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_with_conversation_id( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - """Test that conversation_id is excluded from the completions create call.""" - mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - await azure_chat_client.get_response( - messages=chat_history, - options={"conversation_id": "12345"}, - ) - - call_kwargs = mock_create.call_args.kwargs - assert "conversation_id" not in call_kwargs - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_streaming_with_conversation_id( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk], -) -> None: - """Test that conversation_id is excluded from the streaming completions create call.""" - mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - async for _ in azure_chat_client.get_response( - messages=chat_history, - options={"conversation_id": "12345"}, - stream=True, - ): - pass - - call_kwargs = mock_create.call_args.kwargs - assert "conversation_id" not in call_kwargs - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_agent_with_service_session_id( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - mock_chat_completion_response: ChatCompletion, -) -> None: - """Test that agent.run() with a session containing service_session_id works correctly.""" - mock_create.return_value = mock_chat_completion_response - - azure_chat_client = AzureOpenAIChatClient() - agent = azure_chat_client.as_agent( - name="TestAgent", - instructions="You are a helpful assistant.", - ) - - session = agent.get_session(service_session_id="12345") - response = await agent.run("hello", session=session) - - assert response is not None - call_kwargs = mock_create.call_args.kwargs - assert "conversation_id" not in call_kwargs - - -@tool(approval_mode="never_require") -def get_story_text() -> str: - """Returns a story about Emily and David.""" - return ( - "Emily and David, two passionate scientists, met during a research expedition to Antarctica. " - "Bonded by their love for the natural world and shared curiosity, they uncovered a " - "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " - "of climate change." - ) - - -@tool(approval_mode="never_require") -def get_weather(location: str) -> str: - """Get the current weather for a location.""" - return f"The weather in {location} is sunny and 72°F." - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_response() -> None: - """Test Azure OpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " - "Bonded by their love for the natural world and shared curiosity, they uncovered a " - "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " - "of climate change.", - ) - ) - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = await azure_chat_client.get_response(messages=messages) - - assert response is not None - assert isinstance(response, ChatResponse) - # Check for any relevant keywords that indicate the AI understood the context - assert any( - word in response.text.lower() for word in ["scientists", "research", "antarctica", "glaciology", "climate"] - ) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_response_tools() -> None: - """Test AzureOpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = await azure_chat_client.get_response( - messages=messages, - options={"tools": [get_story_text], "tool_choice": "auto"}, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Emily" in response.text or "David" in response.text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_streaming() -> None: - """Test Azure OpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " - "Bonded by their love for the natural world and shared curiosity, they uncovered a " - "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " - "of climate change.", - ) - ) - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = azure_chat_client.get_response(messages=messages, stream=True) - - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - assert chunk.message_id is not None - assert chunk.response_id is not None - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert "Emily" in full_message or "David" in full_message - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_streaming_tools() -> None: - """Test AzureOpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = azure_chat_client.get_response( - messages=messages, - stream=True, - options={"tools": [get_story_text], "tool_choice": "auto"}, - ) - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert "Emily" in full_message or "David" in full_message - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_basic_run(): - """Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient.""" - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - ) as agent: - # Test basic run - response = await agent.run("Please respond with exactly: 'This is a response test.'") - - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - assert "response test" in response.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_basic_run_streaming(): - """Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient.""" - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - ) as agent: - # Test streaming run - full_text = "" - async for chunk in agent.run( - "Please respond with exactly: 'This is a streaming response test.'", - stream=True, - ): - assert isinstance(chunk, AgentResponseUpdate) - if chunk.text: - full_text += chunk.text - - assert len(full_text) > 0 - assert "streaming response test" in full_text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_session_persistence(): - """Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient.""" - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as agent: - # Create a new session that will be reused - session = agent.create_session() - - # First interaction - response1 = await agent.run("My name is Alice. Remember this.", session=session) - - assert isinstance(response1, AgentResponse) - assert response1.text is not None - - # Second interaction - test memory - response2 = await agent.run("What is my name?", session=session) - - assert isinstance(response2, AgentResponse) - assert response2.text is not None - assert "alice" in response2.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_existing_session(): - """Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances.""" - # First conversation - capture the session - preserved_session = None - - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as first_agent: - # Start a conversation and capture the session - session = first_agent.create_session() - first_response = await first_agent.run("My name is Alice. Remember this.", session=session) - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - - # Preserve the session for reuse - preserved_session = session - - # Second conversation - reuse the session in a new agent instance - if preserved_session: - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as second_agent: - # Reuse the preserved session - second_response = await second_agent.run("What is my name?", session=preserved_session) - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - assert "alice" in second_response.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_chat_client_agent_level_tool_persistence(): - """Test that agent-level tools persist across multiple runs with Azure Chat Client.""" - - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that uses available tools.", - tools=[get_weather], # Agent-level tool - ) as agent: - # First run - agent-level tool should be available - first_response = await agent.run("What's the weather like in Chicago?") - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - # Should use the agent-level weather tool - assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) - - # Second run - agent-level tool should still be available (persistence test) - second_response = await agent.run("What's the weather in Miami?") - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - # Should use the agent-level weather tool again - assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"]) diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py deleted file mode 100644 index a172be577f..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py +++ /dev/null @@ -1,219 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import os -from functools import wraps -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest -from agent_framework.azure import AzureOpenAIEmbeddingClient -from agent_framework.openai import OpenAIEmbeddingOptions -from azure.identity.aio import AzureCliCredential -from openai.types import CreateEmbeddingResponse -from openai.types import Embedding as OpenAIEmbedding -from openai.types.create_embedding_response import Usage - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIEmbeddingClient is deprecated\\..*:DeprecationWarning") - - -def _make_openai_response( - embeddings: list[list[float]], - model: str = "text-embedding-3-small", - prompt_tokens: int = 5, - total_tokens: int = 5, -) -> CreateEmbeddingResponse: - """Helper to create a mock OpenAI embeddings response.""" - data = [OpenAIEmbedding(embedding=emb, index=i, object="embedding") for i, emb in enumerate(embeddings)] - return CreateEmbeddingResponse( - data=data, - model=model, - object="list", - usage=Usage(prompt_tokens=prompt_tokens, total_tokens=total_tokens), - ) - - -@pytest.fixture -def azure_embedding_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Clear ambient Azure OpenAI embedding env vars for deterministic unit tests.""" - for key in ( - "AZURE_OPENAI_ENDPOINT", - "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "AZURE_OPENAI_BASE_URL", - "AZURE_OPENAI_TOKEN_ENDPOINT", - ): - monkeypatch.delenv(key, raising=False) - - -def test_azure_construction_with_deployment_name(azure_embedding_unit_test_env: None) -> None: - client = AzureOpenAIEmbeddingClient( - deployment_name="text-embedding-3-small", - api_key="test-key", - endpoint="https://test.openai.azure.com/", - ) - assert client.model == "text-embedding-3-small" - - -def test_azure_construction_with_existing_client(azure_embedding_unit_test_env: None) -> None: - mock_client = MagicMock() - client = AzureOpenAIEmbeddingClient( - deployment_name="my-deployment", - async_client=mock_client, - ) - assert client.model == "my-deployment" - assert client.client is mock_client - - -def test_azure_construction_missing_deployment_name_raises(azure_embedding_unit_test_env: None) -> None: - with pytest.raises(ValueError, match="deployment name is required"): - AzureOpenAIEmbeddingClient( - api_key="test-key", - endpoint="https://test.openai.azure.com/", - ) - - -def test_azure_construction_missing_credentials_raises(azure_embedding_unit_test_env: None) -> None: - with pytest.raises(ValueError, match="api_key, credential, or a client"): - AzureOpenAIEmbeddingClient( - deployment_name="test", - endpoint="https://test.openai.azure.com/", - ) - - -async def test_azure_get_embeddings(azure_embedding_unit_test_env: None) -> None: - mock_response = _make_openai_response( - embeddings=[[0.1, 0.2]], - ) - mock_async_client = MagicMock() - mock_async_client.embeddings = MagicMock() - mock_async_client.embeddings.create = AsyncMock(return_value=mock_response) - - client = AzureOpenAIEmbeddingClient( - deployment_name="text-embedding-3-small", - async_client=mock_async_client, - ) - - result = await client.get_embeddings(["hello"]) - - assert len(result) == 1 - assert result[0].vector == [0.1, 0.2] - - -def test_azure_otel_provider_name(azure_embedding_unit_test_env: None) -> None: - mock_client = MagicMock() - client = AzureOpenAIEmbeddingClient( - deployment_name="test", - async_client=mock_client, - ) - assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" - - -skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com") - or ( - os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" - and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), - reason="No Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", -) - - -def _with_azure_openai_debug() -> Any: - def decorator(func: Any) -> Any: - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - try: - return await func(*args, **kwargs) - except Exception as exc: - model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") - debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" - if hasattr(exc, "add_note"): - exc.add_note(debug_message) - elif exc.args: - exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) - else: - exc.args = (debug_message,) - raise - - return wrapper - - return decorator - - -def _get_azure_embedding_deployment_name() -> str: - return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] - - -def _create_azure_openai_embedding_client( - *, - api_key: str | None = None, - credential: AzureCliCredential | None = None, -) -> AzureOpenAIEmbeddingClient: - resolved_api_key = ( - api_key if api_key is not None else None if credential is not None else os.getenv("AZURE_OPENAI_API_KEY") - ) - return AzureOpenAIEmbeddingClient( - deployment_name=_get_azure_embedding_deployment_name(), - api_key=resolved_api_key, - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - credential=credential, - ) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_openai_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_azure_openai_get_embeddings() -> None: - """End-to-end test of Azure OpenAI embedding generation.""" - async with AzureCliCredential() as credential: - client = _create_azure_openai_embedding_client(credential=credential) - - result = await client.get_embeddings(["hello world"]) - - assert len(result) == 1 - assert isinstance(result[0].vector, list) - assert len(result[0].vector) > 0 - assert all(isinstance(v, float) for v in result[0].vector) - assert result[0].model_id is not None - assert result.usage is not None - assert result.usage["input_token_count"] > 0 - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_openai_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_azure_openai_get_embeddings_multiple() -> None: - """Test Azure OpenAI embedding generation for multiple inputs.""" - async with AzureCliCredential() as credential: - client = _create_azure_openai_embedding_client(credential=credential) - - result = await client.get_embeddings(["hello", "world", "test"]) - - assert len(result) == 3 - dims = [len(e.vector) for e in result] - assert all(d == dims[0] for d in dims) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_openai_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None: - """Test Azure OpenAI embedding generation with custom dimensions.""" - async with AzureCliCredential() as credential: - client = _create_azure_openai_embedding_client(credential=credential) - - options: OpenAIEmbeddingOptions = {"dimensions": 256} - result = await client.get_embeddings(["hello world"], options=options) - - assert len(result) == 1 - assert len(result[0].vector) == 256 diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py deleted file mode 100644 index da2c346d49..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py +++ /dev/null @@ -1,542 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -import logging -import os -from functools import wraps -from pathlib import Path -from typing import Annotated, Any - -import pytest -from agent_framework import ( - Agent, - AgentResponse, - ChatResponse, - Content, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework.azure import AzureOpenAIResponsesClient -from azure.identity import AzureCliCredential -from pydantic import BaseModel -from pytest import param - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") - -skip_if_azure_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), - reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", -) - - -def _with_azure_openai_debug() -> Any: - def decorator(func: Any) -> Any: - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - try: - return await func(*args, **kwargs) - except Exception as exc: - model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") - debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" - if hasattr(exc, "add_note"): - exc.add_note(debug_message) - elif exc.args: - exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) - else: - exc.args = (debug_message,) - raise - - return wrapper - - return decorator - - -logger = logging.getLogger(__name__) - - -class OutputStruct(BaseModel): - """A structured output for testing purposes.""" - - location: str - weather: str - - -@tool(approval_mode="never_require") -async def get_weather(location: Annotated[str, "The location as a city name"]) -> str: - """Get the current weather in a given location.""" - # Implementation of the tool to get weather - return f"The weather in {location} is sunny and 72°F." - - -async def create_vector_store( - client: AzureOpenAIResponsesClient, -) -> tuple[str, Content]: - """Create a vector store with sample documents for testing.""" - file = await client.client.files.create( - file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), - purpose="assistants", - ) - vector_store = await client.client.vector_stores.create( - name="knowledge_base", - expires_after={"anchor": "last_active_at", "days": 1}, - ) - result = await client.client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id) - if result.last_error is not None: - raise Exception(f"Vector store file processing failed with status: {result.last_error.message}") - - return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) - - -async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str, vector_store_id: str) -> None: - """Delete the vector store after tests.""" - - await client.client.vector_stores.delete(vector_store_id=vector_store_id) - await client.client.files.delete(file_id=file_id) - - -def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization - azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - - assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_validation_fail() -> None: - # Test successful initialization - with pytest.raises(ValueError): - AzureOpenAIResponsesClient(api_key="34523", deployment_name={"test": "dict"}) # type: ignore - - -def test_init_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization - model_id = "test_model_id" - azure_responses_client = AzureOpenAIResponsesClient(deployment_name=model_id) - - assert azure_responses_client.model == model_id - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_model_id_kwarg(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test that model_id kwarg correctly sets the deployment name (issue #4299).""" - azure_responses_client = AzureOpenAIResponsesClient(model_id="gpt-4o") - - assert azure_responses_client.model == "gpt-4o" - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_model_id_kwarg_does_not_override_deployment_name( - azure_openai_unit_test_env: dict[str, str], -) -> None: - """Test that deployment_name takes precedence over model_id kwarg (issue #4299).""" - azure_responses_client = AzureOpenAIResponsesClient(deployment_name="my-deployment", model_id="gpt-4o") - - assert azure_responses_client.model == "my-deployment" - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_model_id_kwarg_none(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test that model_id=None does not override the env-var deployment name.""" - azure_responses_client = AzureOpenAIResponsesClient(model_id=None) - - assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - - -def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None: - default_headers = {"X-Unit-Test": "test-guid"} - - # Test successful initialization - azure_responses_client = AzureOpenAIResponsesClient( - default_headers=default_headers, - ) - - assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - # Assert that the default header we added is present in the client's default headers - for key, value in default_headers.items(): - assert key in azure_responses_client.client.default_headers - assert azure_responses_client.client.default_headers[key] == value - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True) -def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ValueError): - AzureOpenAIResponsesClient() - - -def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: - default_headers = {"X-Unit-Test": "test-guid"} - - settings = { - "deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - "api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - "default_headers": default_headers, - } - - azure_responses_client = AzureOpenAIResponsesClient.from_dict(settings) - dumped_settings = azure_responses_client.to_dict() - assert dumped_settings["deployment_name"] == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert "api_key" not in dumped_settings - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - # Assert that the 'User-Agent' header is not present in the dumped_settings default headers - assert "User-Agent" not in dumped_settings["default_headers"] - - -# region Integration Tests - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@pytest.mark.parametrize( - "option_name,option_value,needs_validation", - [ - # Simple ChatOptions - just verify they don't fail - param("max_tokens", 500, False, id="max_tokens"), - param("seed", 123, False, id="seed"), - param("user", "test-user-id", False, id="user"), - param("metadata", {"test_key": "test_value"}, False, id="metadata"), - param("frequency_penalty", 0.5, False, id="frequency_penalty"), - param("presence_penalty", 0.3, False, id="presence_penalty"), - param("stop", ["END"], False, id="stop"), - param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"), - param("tool_choice", "none", True, id="tool_choice_none"), - # OpenAIResponsesOptions - just verify they don't fail - param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), - param("truncation", "auto", False, id="truncation"), - param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), - param("max_tool_calls", 3, False, id="max_tool_calls"), - # Complex options requiring output validation - param("tools", [get_weather], True, id="tools_function"), - param("tool_choice", "auto", True, id="tool_choice_auto"), - param( - "tool_choice", - {"mode": "required", "required_function_name": "get_weather"}, - True, - id="tool_choice_required", - ), - param("response_format", OutputStruct, True, id="response_format_pydantic"), - param( - "response_format", - { - "type": "json_schema", - "json_schema": { - "name": "WeatherDigest", - "strict": True, - "schema": { - "title": "WeatherDigest", - "type": "object", - "properties": { - "location": {"type": "string"}, - "conditions": {"type": "string"}, - "temperature_c": {"type": "number"}, - "advisory": {"type": "string"}, - }, - "required": [ - "location", - "conditions", - "temperature_c", - "advisory", - ], - "additionalProperties": False, - }, - }, - }, - True, - id="response_format_runtime_json_schema", - ), - ], -) -@_with_azure_openai_debug() -async def test_integration_options( - option_name: str, - option_value: Any, - needs_validation: bool, -) -> None: - """Parametrized test covering all ChatOptions and OpenAIResponsesOptions. - - Tests both streaming and non-streaming modes for each option to ensure - they don't cause failures. Options marked with needs_validation also - check that the feature actually works correctly. - """ - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response - client.function_invocation_configuration["max_iterations"] = 2 - - # Prepare test message - if option_name == "tools" or option_name == "tool_choice": - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] - elif option_name == "response_format": - # Use prompt that works well with structured output - messages = [ - Message(role="user", text="The weather in Seattle is sunny"), - Message(role="user", text="What is the weather in Seattle?"), - ] - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name == "tool_choice": - options["tools"] = [get_weather] - - # Test streaming mode - response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name == "tools" or option_name == "tool_choice": - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name == "response_format": - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_web_search() -> None: - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - response = await client.get_response( - messages=[ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - options={ - "tools": [ - AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) - ] - }, - stream=True, - ).get_final_response() - - assert response.text is not None - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_file_search() -> None: - """Test Azure responses client with file search tool.""" - azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - file_id, vector_store = await create_vector_store(azure_responses_client) - try: - # Test that the client will use the file search tool - response = await azure_responses_client.get_response( - messages=[ - Message( - role="user", - text="What is the weather today? Do a file search to find the answer.", - ) - ], - options={ - "tools": [ - AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) - ], - "tool_choice": "auto", - }, - ) - - assert "sunny" in response.text.lower() - assert "75" in response.text - finally: - await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_file_search_streaming() -> None: - """Test Azure responses client with file search tool and streaming.""" - azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - file_id, vector_store = await create_vector_store(azure_responses_client) - # Test that the client will use the file search tool - try: - response_stream = azure_responses_client.get_response( - messages=[ - Message( - role="user", - text="What is the weather today? Do a file search to find the answer.", - ) - ], - stream=True, - options={ - "tools": [ - AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) - ], - "tool_choice": "auto", - }, - ) - - full_response = await response_stream.get_final_response() - assert "sunny" in full_response.text.lower() - assert "75" in full_response.text - finally: - await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_agent_hosted_mcp_tool() -> None: - """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - response = await client.get_response( - messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], - options={ - # this needs to be high enough to handle the full MCP tool response. - "max_tokens": 5000, - "tools": AzureOpenAIResponsesClient.get_mcp_tool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), - }, - ) - assert isinstance(response, ChatResponse) - # MCP server may return empty response intermittently - skip test rather than fail - if not response.text: - pytest.skip("MCP server returned empty response - service-side issue") - # Should contain Azure-related content since it's asking about Azure CLI - assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_agent_hosted_code_interpreter_tool(): - """Test Azure Responses Client agent with code interpreter tool.""" - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - - response = await client.get_response( - messages=[ - Message( - role="user", - text="Calculate the sum of numbers from 1 to 10 using Python code.", - ) - ], - options={ - "tools": [AzureOpenAIResponsesClient.get_code_interpreter_tool()], - }, - ) - # Should contain calculation result (sum of 1-10 = 55) or code execution content - contains_relevant_content = any( - term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"] - ) - assert contains_relevant_content or len(response.text.strip()) > 10 - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_agent_existing_session(): - """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" - # First conversation - capture the session - preserved_session = None - - async with Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as first_agent: - # Start a conversation and capture the session - session = first_agent.create_session() - first_response = await first_agent.run( - "My hobby is photography. Remember this.", session=session, options={"store": True} - ) - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - - # Preserve the session for reuse - preserved_session = session - - # Second conversation - reuse the session in a new agent instance - if preserved_session: - async with Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as second_agent: - # Reuse the preserved session - second_response = await second_agent.run( - "What is my hobby?", session=preserved_session, options={"store": True} - ) - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - assert "photography" in second_response.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_responses_client_tool_rich_content_image() -> None: - """Test that Azure OpenAI Responses client can handle tool results containing images.""" - image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" - image_bytes = image_path.read_bytes() - - @tool(approval_mode="never_require") - def get_test_image() -> Content: - """Return a test image for analysis.""" - return Content.from_data(data=image_bytes, media_type="image/jpeg") - - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - client.function_invocation_configuration["max_iterations"] = 2 - - for streaming in [False, True]: - messages = [ - Message( - role="user", - text="Call the get_test_image tool and describe what you see.", - ) - ] - options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} - - if streaming: - response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() - else: - response = await client.get_response(messages=messages, options=options) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None - assert len(response.text) > 0 - # sample_image.jpg contains a photo of a house; the model should mention it. - assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py deleted file mode 100644 index bbf10e7b88..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py +++ /dev/null @@ -1,131 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import warnings -from unittest.mock import MagicMock - -import pytest -from agent_framework import SupportsChatGetResponse - -warnings.filterwarnings( - "ignore", - message=r"RawAzureAIClient is deprecated\..*", - category=DeprecationWarning, -) - -from agent_framework.azure import AzureOpenAIResponsesClient # noqa: E402 -from azure.identity import AzureCliCredential # noqa: E402 - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") - - -def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with an existing AIProjectClient.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - # Create a mock AIProjectClient that returns a mock AsyncOpenAI client - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_client=mock_project_client, - deployment_name="gpt-4o", - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with a project endpoint and credential.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_endpoint="https://test-project.services.ai.azure.com", - deployment_name="gpt-4o", - credential=AzureCliCredential(), - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_create_client_from_project_with_project_client() -> None: - """Test _create_client_from_project with an existing project client.""" - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=mock_project_client, - project_endpoint=None, - credential=None, - ) - - assert result is mock_openai_client - mock_project_client.get_openai_client.assert_called_once() - - -def test_create_client_from_project_with_endpoint() -> None: - """Test _create_client_from_project with a project endpoint.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_credential = MagicMock() - - with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: - mock_instance = MockAIProjectClient.return_value - mock_instance.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=mock_credential, - ) - - assert result is mock_openai_client - MockAIProjectClient.assert_called_once() - mock_instance.get_openai_client.assert_called_once() - - -def test_create_client_from_project_missing_endpoint() -> None: - """Test _create_client_from_project raises error when endpoint is missing.""" - with pytest.raises(ValueError, match="project endpoint is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint=None, - credential=MagicMock(), - ) - - -def test_create_client_from_project_missing_credential() -> None: - """Test _create_client_from_project raises error when credential is missing.""" - with pytest.raises(ValueError, match="credential is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=None, - ) diff --git a/python/packages/azure-ai/tests/azure_openai/test_entra_id_authentication.py b/python/packages/azure-ai/tests/azure_openai/test_entra_id_authentication.py deleted file mode 100644 index a741459fd6..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_entra_id_authentication.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from unittest.mock import MagicMock, patch - -import pytest -from agent_framework.exceptions import ChatClientInvalidAuthException -from azure.core.credentials import TokenCredential -from azure.core.credentials_async import AsyncTokenCredential - -from agent_framework_azure_ai._entra_id_authentication import ( - resolve_credential_to_token_provider, -) - -TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default" - - -def test_resolve_sync_credential_returns_provider() -> None: - """Test that a sync TokenCredential is resolved via azure.identity.get_bearer_token_provider.""" - mock_credential = MagicMock(spec=TokenCredential) - mock_provider = MagicMock(return_value="token-string") - - with patch("azure.identity.get_bearer_token_provider", return_value=mock_provider) as mock_gbtp: - result = resolve_credential_to_token_provider(mock_credential, TOKEN_ENDPOINT) - - mock_gbtp.assert_called_once_with(mock_credential, TOKEN_ENDPOINT) - assert result is mock_provider - - -def test_resolve_async_credential_returns_provider() -> None: - """Test that an AsyncTokenCredential is resolved via azure.identity.aio.get_bearer_token_provider.""" - mock_credential = MagicMock(spec=AsyncTokenCredential) - mock_provider = MagicMock(return_value="token-string") - - with patch("azure.identity.aio.get_bearer_token_provider", return_value=mock_provider) as mock_gbtp: - result = resolve_credential_to_token_provider(mock_credential, TOKEN_ENDPOINT) - - mock_gbtp.assert_called_once_with(mock_credential, TOKEN_ENDPOINT) - assert result is mock_provider - - -def test_resolve_callable_provider_passthrough() -> None: - """Test that a callable token provider is returned as-is, without needing token_endpoint.""" - my_provider = lambda: "my-token" # noqa: E731 - - # Works with token_endpoint - assert resolve_credential_to_token_provider(my_provider, TOKEN_ENDPOINT) is my_provider - - # Also works without token_endpoint - assert resolve_credential_to_token_provider(my_provider, None) is my_provider - assert resolve_credential_to_token_provider(my_provider, "") is my_provider - - -def test_resolve_missing_endpoint_raises() -> None: - """Test that missing token endpoint raises ChatClientInvalidAuthException.""" - mock_credential = MagicMock(spec=TokenCredential) - - with pytest.raises(ChatClientInvalidAuthException, match="A token endpoint must be provided"): - resolve_credential_to_token_provider(mock_credential, "") - - with pytest.raises(ChatClientInvalidAuthException, match="A token endpoint must be provided"): - resolve_credential_to_token_provider(mock_credential, None) # type: ignore[arg-type] diff --git a/python/packages/azure-ai/tests/conftest.py b/python/packages/azure-ai/tests/conftest.py deleted file mode 100644 index 3f635e8213..0000000000 --- a/python/packages/azure-ai/tests/conftest.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -from pytest import fixture - - -@fixture -def exclude_list(request: Any) -> list[str]: - """Fixture that returns a list of environment variables to exclude.""" - return request.param if hasattr(request, "param") else [] - - -@fixture -def override_env_param_dict(request: Any) -> dict[str, str]: - """Fixture that returns a dict of environment variables to override.""" - return request.param if hasattr(request, "param") else {} - - -@fixture() -def azure_ai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore - """Fixture to set environment variables for AzureAISettings.""" - - if exclude_list is None: - exclude_list = [] - - if override_env_param_dict is None: - override_env_param_dict = {} - - env_vars = { - "AZURE_AI_PROJECT_ENDPOINT": "https://test-project.cognitiveservices.azure.com/", - "AZURE_AI_MODEL_DEPLOYMENT_NAME": "test-gpt-4o", - } - - env_vars.update(override_env_param_dict) # type: ignore - - for key, value in env_vars.items(): - if key in exclude_list: - monkeypatch.delenv(key, raising=False) # type: ignore - continue - monkeypatch.setenv(key, value) # type: ignore - - return env_vars - - -@fixture -def mock_agents_client() -> MagicMock: - """Fixture that provides a mock AgentsClient.""" - mock_client = MagicMock() - - # Mock agents property - mock_client.create_agent = AsyncMock() - mock_client.delete_agent = AsyncMock() - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.id = "test-agent-id" - mock_client.create_agent.return_value = mock_agent - - # Mock threads property - mock_client.threads = MagicMock() - mock_client.threads.create = AsyncMock() - mock_client.messages.create = AsyncMock() - - # Mock runs property - mock_client.runs = MagicMock() - mock_client.runs.list = AsyncMock() - mock_client.runs.cancel = AsyncMock() - mock_client.runs.stream = AsyncMock() - mock_client.runs.submit_tool_outputs_stream = AsyncMock() - - return mock_client - - -@fixture -def mock_azure_credential() -> MagicMock: - """Fixture that provides a mock AsyncTokenCredential.""" - return MagicMock() diff --git a/python/packages/azure-ai/tests/resources/employees.pdf b/python/packages/azure-ai/tests/resources/employees.pdf deleted file mode 100644 index 9590e9d30f..0000000000 --- a/python/packages/azure-ai/tests/resources/employees.pdf +++ /dev/null @@ -1,76 +0,0 @@ -%PDF-1.7 -%���� -1 0 obj -<>/Metadata 132 0 R/ViewerPreferences 133 0 R>> -endobj -2 0 obj -<> -endobj -3 0 obj -<> -endobj -4 0 obj -<>>>/Contents 6 0 R>> -endobj -5 0 obj -<> -endobj -6 0 obj -<> -stream -BT -/F1 12 Tf -50 750 Td -(Employee Directory) Tj -0 -30 Td -(Name: John Smith) Tj -0 -15 Td -(Department: Engineering) Tj -0 -15 Td -(Age: 28) Tj -0 -30 Td -(Name: Alice Johnson) Tj -0 -15 Td -(Department: Sales) Tj -0 -15 Td -(Age: 24) Tj -0 -30 Td -(Name: Bob Wilson) Tj -0 -15 Td -(Department: Marketing) Tj -0 -15 Td -(Age: 35) Tj -ET -endstream -endobj -22 0 obj -<> -endobj -132 0 obj -<> -endobj -133 0 obj -<> -endobj -xref -0 10 -0000000000 65535 f -0000000015 00000 n -0000000152 00000 n -0000000209 00000 n -0000000300 00000 n -0000000420 00000 n -0000000490 00000 n -0000000000 65535 f -0000000000 65535 f -0000000000 65535 f -22 1 -0000000740 00000 n -132 2 -0000000780 00000 n -0000000820 00000 n -trailer -<> -startxref -860 -%%EOF \ No newline at end of file diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py deleted file mode 100644 index 025f882eb6..0000000000 --- a/python/packages/azure-ai/tests/test_agent_provider.py +++ /dev/null @@ -1,773 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import os -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - Agent, - tool, -) -from azure.ai.agents.models import ( - Agent as AzureAgent, -) -from azure.ai.agents.models import ( - CodeInterpreterToolDefinition, -) -from pydantic import BaseModel - -from agent_framework_azure_ai import ( - AzureAIAgentClient, - AzureAIAgentsProvider, - AzureAISettings, -) -from agent_framework_azure_ai._shared import ( - from_azure_ai_agent_tools, - to_azure_ai_agent_tools, -) - -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/"), - reason="No real AZURE_AI_PROJECT_ENDPOINT provided; skipping integration tests.", -) - -# region Provider Initialization Tests - - -def test_provider_init_with_agents_client(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentsProvider initialization with existing AgentsClient.""" - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - assert provider._agents_client is mock_agents_client # type: ignore - assert provider._should_close_client is False # type: ignore - - -def test_provider_init_with_credential( - azure_ai_unit_test_env: dict[str, str], - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIAgentsProvider initialization with credential.""" - with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class: - mock_client_instance = MagicMock() - mock_client_class.return_value = mock_client_instance - - provider = AzureAIAgentsProvider(credential=mock_azure_credential) - - mock_client_class.assert_called_once() - assert provider._agents_client is mock_client_instance # type: ignore - assert provider._should_close_client is True # type: ignore - - -def test_provider_init_with_explicit_endpoint(mock_azure_credential: MagicMock) -> None: - """Test AzureAIAgentsProvider initialization with explicit endpoint.""" - with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class: - mock_client_instance = MagicMock() - mock_client_class.return_value = mock_client_instance - - provider = AzureAIAgentsProvider( - project_endpoint="https://custom-endpoint.com/", - credential=mock_azure_credential, - ) - - mock_client_class.assert_called_once() - call_kwargs = mock_client_class.call_args.kwargs - assert call_kwargs["endpoint"] == "https://custom-endpoint.com/" - assert provider._should_close_client is True # type: ignore - - -def test_provider_init_missing_endpoint_raises( - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIAgentsProvider raises error when endpoint is missing.""" - # Mock load_settings to return a dict with None for project_endpoint - with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError) as exc_info: - AzureAIAgentsProvider(credential=mock_azure_credential) - - assert "project endpoint is required" in str(exc_info.value).lower() - - -def test_provider_init_missing_credential_raises(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIAgentsProvider raises error when credential is missing.""" - with pytest.raises(ValueError) as exc_info: - AzureAIAgentsProvider() - - assert "credential is required" in str(exc_info.value).lower() - - -# endregion - -# region Context Manager Tests - - -async def test_provider_context_manager_closes_client(mock_agents_client: MagicMock) -> None: - """Test that context manager closes client when it was created by provider.""" - with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class: - mock_client_instance = AsyncMock() - mock_client_class.return_value = mock_client_instance - - with patch.object(AzureAIAgentsProvider, "__init__", lambda self: None): # type: ignore - provider = AzureAIAgentsProvider.__new__(AzureAIAgentsProvider) - provider._agents_client = mock_client_instance # type: ignore - provider._should_close_client = True # type: ignore - provider._settings = AzureAISettings(project_endpoint="https://test.com") # type: ignore - - async with provider: - pass - - mock_client_instance.close.assert_called_once() - - -async def test_provider_context_manager_does_not_close_external_client(mock_agents_client: MagicMock) -> None: - """Test that context manager does not close externally provided client.""" - mock_agents_client.close = AsyncMock() - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - async with provider: - pass - - mock_agents_client.close.assert_not_called() - - -# endregion - -# region create_agent Tests - - -async def test_create_agent_basic( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating a basic agent.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = "A test agent" - mock_agent.instructions = "Be helpful" - mock_agent.model = "gpt-4" - mock_agent.temperature = 0.7 - mock_agent.top_p = 0.9 - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = await provider.create_agent( - name="TestAgent", - instructions="Be helpful", - description="A test agent", - ) - - assert isinstance(agent, Agent) - assert agent.name == "TestAgent" - assert agent.id == "test-agent-id" - mock_agents_client.create_agent.assert_called_once() - - -async def test_create_agent_with_model( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating an agent with explicit model.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "custom-model" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - await provider.create_agent(name="TestAgent", model="custom-model") - - call_kwargs = mock_agents_client.create_agent.call_args.kwargs - assert call_kwargs["model"] == "custom-model" - - -async def test_create_agent_with_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating an agent with tools.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - @tool(approval_mode="never_require") - def get_weather(city: str) -> str: - """Get weather for a city.""" - return f"Weather in {city}" - - await provider.create_agent(name="TestAgent", tools=get_weather) - - call_kwargs = mock_agents_client.create_agent.call_args.kwargs - assert "tools" in call_kwargs - assert len(call_kwargs["tools"]) > 0 - - -async def test_create_agent_with_response_format( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating an agent with structured response format via default_options.""" - - class WeatherResponse(BaseModel): - temperature: float - description: str - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - await provider.create_agent( - name="TestAgent", - default_options={"response_format": WeatherResponse}, - ) - - call_kwargs = mock_agents_client.create_agent.call_args.kwargs - assert "response_format" in call_kwargs - - -async def test_create_agent_missing_model_raises( - mock_agents_client: MagicMock, -) -> None: - """Test that create_agent raises error when model is not specified.""" - # Create provider with mocked settings that has no model - with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - await provider.create_agent(name="TestAgent") - - assert "model deployment name is required" in str(exc_info.value).lower() - - -# endregion - -# region get_agent Tests - - -async def test_get_agent_by_id( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test getting an agent by ID.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "existing-agent-id" - mock_agent.name = "ExistingAgent" - mock_agent.description = "An existing agent" - mock_agent.instructions = "Be helpful" - mock_agent.model = "gpt-4" - mock_agent.temperature = 0.7 - mock_agent.top_p = 0.9 - mock_agent.tools = [] - mock_agents_client.get_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = await provider.get_agent("existing-agent-id") - - assert isinstance(agent, Agent) - assert agent.id == "existing-agent-id" - mock_agents_client.get_agent.assert_called_once_with("existing-agent-id") - - -async def test_get_agent_with_function_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test getting an agent that has function tools requires tool implementations.""" - mock_function_tool = MagicMock() - mock_function_tool.type = "function" - mock_function_tool.function = MagicMock() - mock_function_tool.function.name = "get_weather" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-with-tools" - mock_agent.name = "AgentWithTools" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_function_tool] - mock_agents_client.get_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - await provider.get_agent("agent-with-tools") - - assert "get_weather" in str(exc_info.value) - - -async def test_get_agent_with_provided_function_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test getting an agent with function tools when implementations are provided.""" - mock_function_tool = MagicMock() - mock_function_tool.type = "function" - mock_function_tool.function = MagicMock() - mock_function_tool.function.name = "get_weather" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-with-tools" - mock_agent.name = "AgentWithTools" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_function_tool] - mock_agents_client.get_agent = AsyncMock(return_value=mock_agent) - - @tool(approval_mode="never_require") - def get_weather(city: str) -> str: - """Get weather for a city.""" - return f"Weather in {city}" - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = await provider.get_agent("agent-with-tools", tools=get_weather) - - assert isinstance(agent, Agent) - assert agent.id == "agent-with-tools" - - -# endregion - -# region as_agent Tests - - -def test_as_agent_wraps_without_http( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent wraps Agent object without making HTTP calls.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "wrap-agent-id" - mock_agent.name = "WrapAgent" - mock_agent.description = "Wrapped agent" - mock_agent.instructions = "Be helpful" - mock_agent.model = "gpt-4" - mock_agent.temperature = 0.5 - mock_agent.top_p = 0.8 - mock_agent.tools = [] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = provider.as_agent(mock_agent) - - assert isinstance(agent, Agent) - assert agent.id == "wrap-agent-id" - assert agent.name == "WrapAgent" - # Ensure no HTTP calls were made - mock_agents_client.get_agent.assert_not_called() - mock_agents_client.create_agent.assert_not_called() - - -def test_as_agent_with_function_tools_validates( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent validates that function tool implementations are provided.""" - mock_function_tool = MagicMock() - mock_function_tool.type = "function" - mock_function_tool.function = MagicMock() - mock_function_tool.function.name = "my_function" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_function_tool] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - provider.as_agent(mock_agent) - - assert "my_function" in str(exc_info.value) - - -def test_as_agent_with_hosted_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent excludes hosted tools from local tools (they stay on the server agent).""" - mock_code_interpreter = MagicMock() - mock_code_interpreter.type = "code_interpreter" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_code_interpreter] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = provider.as_agent(mock_agent) - - assert isinstance(agent, Agent) - # Hosted tools (code_interpreter, file_search, etc.) are already on the server agent - # and should NOT be in local tools to avoid re-sending them at run time - tools = agent.default_options.get("tools") or [] - assert not any(isinstance(t, dict) and t.get("type") == "code_interpreter" for t in tools) - - -def test_as_agent_with_dict_function_tools_validates( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent validates dict-format function tools require implementations.""" - # Dict-based function tool (as returned by some Azure AI SDK operations) - dict_function_tool = { # type: ignore - "type": "function", - "function": { - "name": "dict_based_function", - "description": "A function defined as dict", - "parameters": {"type": "object", "properties": {}}, - }, - } - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [dict_function_tool] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - provider.as_agent(mock_agent) - - assert "dict_based_function" in str(exc_info.value) - - -def test_as_agent_with_dict_function_tools_provided( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent succeeds when dict-format function tools have implementations provided.""" - dict_function_tool = { # type: ignore - "type": "function", - "function": { - "name": "dict_based_function", - "description": "A function defined as dict", - "parameters": {"type": "object", "properties": {}}, - }, - } - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [dict_function_tool] - - @tool - def dict_based_function() -> str: - """A function implementation.""" - return "result" - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = provider.as_agent(mock_agent, tools=dict_based_function) - - assert isinstance(agent, Agent) - assert agent.id == "agent-id" - - -# endregion - -# region Tool Conversion Tests - to_azure_ai_agent_tools - - -def test_to_azure_ai_agent_tools_empty() -> None: - """Test converting empty tools list.""" - result = to_azure_ai_agent_tools(None) - assert result == [] - - result = to_azure_ai_agent_tools([]) - assert result == [] - - -def test_to_azure_ai_agent_tools_function() -> None: - """Test converting FunctionTool to Azure tool definition.""" - - @tool(approval_mode="never_require") - def get_weather(city: str) -> str: - """Get weather for a city.""" - return f"Weather in {city}" - - result = to_azure_ai_agent_tools([get_weather]) - - assert len(result) == 1 - assert result[0]["type"] == "function" - assert result[0]["function"]["name"] == "get_weather" - - -def test_to_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting code_interpreter dict tool.""" - tool = AzureAIAgentClient.get_code_interpreter_tool() - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert isinstance(result[0], CodeInterpreterToolDefinition) - - -def test_to_azure_ai_agent_tools_file_search() -> None: - """Test converting file_search dict tool with vector stores.""" - tool = AzureAIAgentClient.get_file_search_tool(vector_store_ids=["vs-123"]) - run_options: dict[str, Any] = {} - - result = to_azure_ai_agent_tools([tool], run_options) - - assert len(result) == 1 - assert "tool_resources" in run_options - - -def test_to_azure_ai_agent_tools_web_search_bing_grounding(monkeypatch: Any) -> None: - """Test converting web_search dict tool for Bing Grounding.""" - # Use a properly formatted connection ID as required by Azure SDK - valid_conn_id = ( - "/subscriptions/test-sub/resourceGroups/test-rg/" - "providers/Microsoft.CognitiveServices/accounts/test-account/" - "projects/test-project/connections/test-connection" - ) - tool = AzureAIAgentClient.get_web_search_tool(bing_connection_id=valid_conn_id) - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) > 0 - - -def test_to_azure_ai_agent_tools_web_search_custom(monkeypatch: Any) -> None: - """Test converting web_search dict tool for Custom Bing Search.""" - tool = AzureAIAgentClient.get_web_search_tool( - bing_custom_connection_id="custom-conn-id", - bing_custom_instance_id="my-instance", - ) - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) > 0 - - -def test_to_azure_ai_agent_tools_web_search_missing_config(monkeypatch: Any) -> None: - """Test converting web_search dict tool without bing config returns empty.""" - monkeypatch.delenv("BING_CONNECTION_ID", raising=False) - monkeypatch.delenv("BING_CUSTOM_CONNECTION_ID", raising=False) - monkeypatch.delenv("BING_CUSTOM_INSTANCE_NAME", raising=False) - tool = {"type": "web_search"} - - result = to_azure_ai_agent_tools([tool]) - - # web_search without bing connection is passed through as dict - assert len(result) == 1 - - -def test_to_azure_ai_agent_tools_mcp() -> None: - """Test converting MCP dict tool.""" - tool = AzureAIAgentClient.get_mcp_tool( - name="my mcp server", - url="https://mcp.example.com", - ) - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) > 0 - - -def test_to_azure_ai_agent_tools_dict_passthrough() -> None: - """Test that dict tools are passed through.""" - tool = {"type": "custom_tool", "config": {"key": "value"}} - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == tool - - -def test_to_azure_ai_agent_tools_unsupported_type() -> None: - """Test that unsupported tool types pass through unchanged.""" - - class UnsupportedTool: - pass - - unsupported = UnsupportedTool() - result = to_azure_ai_agent_tools([unsupported]) # type: ignore - assert len(result) == 1 - assert result[0] is unsupported # Passed through unchanged - - -# endregion - -# region Tool Conversion Tests - from_azure_ai_agent_tools - - -def test_from_azure_ai_agent_tools_empty() -> None: - """Test converting empty tools list.""" - result = from_azure_ai_agent_tools(None) - assert result == [] - - result = from_azure_ai_agent_tools([]) - assert result == [] - - -def test_from_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting CodeInterpreterToolDefinition.""" - tool = CodeInterpreterToolDefinition() - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - - -def test_from_azure_ai_agent_tools_code_interpreter_dict() -> None: - """Test converting code_interpreter dict.""" - tool = {"type": "code_interpreter"} - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - - -def test_from_azure_ai_agent_tools_file_search_dict() -> None: - """Test converting file_search dict with vector store IDs.""" - tool = { - "type": "file_search", - "file_search": {"vector_store_ids": ["vs-123", "vs-456"]}, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0]["type"] == "file_search" - assert result[0]["vector_store_ids"] == ["vs-123", "vs-456"] - - -def test_from_azure_ai_agent_tools_bing_grounding_dict() -> None: - """Test converting bing_grounding dict.""" - tool = { - "type": "bing_grounding", - "bing_grounding": {"connection_id": "conn-123"}, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0]["type"] == "bing_grounding" - assert result[0]["connection_id"] == "conn-123" - - -def test_from_azure_ai_agent_tools_bing_custom_search_dict() -> None: - """Test converting bing_custom_search dict.""" - tool = { - "type": "bing_custom_search", - "bing_custom_search": { - "connection_id": "custom-conn", - "instance_name": "my-instance", - }, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0]["type"] == "bing_custom_search" - assert result[0]["connection_id"] == "custom-conn" - assert result[0]["instance_name"] == "my-instance" - - -def test_from_azure_ai_agent_tools_mcp_dict() -> None: - """Test that mcp dict is skipped (hosted on Azure, no local handling needed).""" - tool = { - "type": "mcp", - "mcp": { - "server_label": "my_server", - "server_url": "https://mcp.example.com", - "allowed_tools": ["tool1"], - }, - } - - result = from_azure_ai_agent_tools([tool]) - - # MCP tools are hosted on Azure agent, skipped in conversion - assert len(result) == 0 - - -def test_from_azure_ai_agent_tools_function_dict() -> None: - """Test converting function tool dict (returned as-is).""" - tool: dict[str, Any] = { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get weather", - "parameters": {}, - }, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == tool - - -def test_from_azure_ai_agent_tools_unknown_dict() -> None: - """Test converting unknown tool type dict.""" - tool = {"type": "unknown_tool", "config": "value"} - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == tool - - -# endregion diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py deleted file mode 100644 index f7560a6443..0000000000 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ /dev/null @@ -1,1941 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework._serialization import SerializationMixin -from agent_framework._settings import load_settings -from agent_framework.exceptions import ChatClientInvalidRequestException -from azure.ai.agents.models import ( - AgentsNamedToolChoice, - AgentsNamedToolChoiceType, - AgentsToolChoiceOptionMode, - CodeInterpreterToolDefinition, - MessageDeltaChunk, - MessageDeltaTextContent, - MessageDeltaTextFileCitationAnnotation, - MessageDeltaTextFilePathAnnotation, - MessageDeltaTextUrlCitationAnnotation, - MessageInputTextBlock, - RequiredFunctionToolCall, - RequiredMcpToolCall, - RunStatus, - SubmitToolApprovalAction, - SubmitToolOutputsAction, - ThreadRun, -) -from azure.core.credentials_async import AsyncTokenCredential -from pydantic import BaseModel, Field - -from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings - - -def create_test_azure_ai_chat_client( - mock_agents_client: MagicMock, - agent_id: str | None = None, - thread_id: str | None = None, - azure_ai_settings: AzureAISettings | None = None, - should_cleanup_agent: bool = True, - agent_name: str | None = None, -) -> AzureAIAgentClient: - """Helper function to create AzureAIAgentClient instances for testing, bypassing normal validation.""" - if azure_ai_settings is None: - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - # Create client instance directly - client = object.__new__(AzureAIAgentClient) - - # Set attributes directly - client.agents_client = mock_agents_client - client.credential = None - client.agent_id = agent_id - client.agent_name = agent_name - client.agent_description = None - client.model_id = azure_ai_settings.get("model_deployment_name") - client.thread_id = thread_id - client.should_cleanup_agent = should_cleanup_agent - client._agent_created = False - client._should_close_client = False - client._agent_definition = None - client._azure_search_tool_calls = [] # Add the new instance variable - client.additional_properties = {} - client.middleware = None - client.chat_middleware = [] - client.function_middleware = [] - client._cached_chat_middleware_pipeline = None - client._cached_function_middleware_pipeline = None - client.otel_provider_name = "azure.ai" - client.function_invocation_configuration = { - "enabled": True, - "max_iterations": 5, - "max_consecutive_errors_per_request": 0, - "terminate_on_unknown_calls": False, - "additional_tools": [], - "include_detailed_errors": False, - } - - return client - - -def test_init_emits_updated_deprecation_warning(mock_agents_client: MagicMock) -> None: - """Test that construction emits the updated class deprecation warning.""" - with pytest.deprecated_call(match="V1 Agents Service API and has no direct replacement"): - AzureAIAgentClient( - agents_client=mock_agents_client, - agent_id="test-agent", - ) - - -def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAISettings initialization.""" - settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - assert settings["project_endpoint"] == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - assert settings["model_deployment_name"] == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - - -def test_azure_ai_settings_init_with_explicit_values() -> None: - """Test AzureAISettings initialization with explicit values.""" - settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint="https://custom-endpoint.com/", - model_deployment_name="custom-model", - ) - - assert settings["project_endpoint"] == "https://custom-endpoint.com/" - assert settings["model_deployment_name"] == "custom-model" - - -def test_azure_ai_chat_client_init_with_client(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentClient initialization with existing agents_client.""" - client = create_test_azure_ai_chat_client( - mock_agents_client, agent_id="existing-agent-id", thread_id="test-thread-id" - ) - - assert client.agents_client is mock_agents_client - assert client.agent_id == "existing-agent-id" - assert client.thread_id == "test-thread-id" - assert isinstance(client, SupportsChatGetResponse) - - -def test_azure_ai_chat_client_init_auto_create_client( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test AzureAIAgentClient initialization with auto-created agents_client.""" - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", **azure_ai_unit_test_env) # type: ignore - - # Create client instance directly - chat_client = object.__new__(AzureAIAgentClient) - chat_client.agents_client = mock_agents_client - chat_client.agent_id = None - chat_client.thread_id = None - chat_client._should_close_client = False # type: ignore - chat_client.credential = None - chat_client.model_id = azure_ai_settings.get("model_deployment_name") - chat_client.agent_name = None - chat_client.additional_properties = {} - chat_client.middleware = None - chat_client.chat_middleware = [] - chat_client.function_middleware = [] - chat_client._cached_chat_middleware_pipeline = None - chat_client._cached_function_middleware_pipeline = None - - assert chat_client.agents_client is mock_agents_client - assert chat_client.agent_id is None - - -def test_azure_ai_chat_client_init_missing_project_endpoint() -> None: - """Test AzureAIAgentClient initialization when project_endpoint is missing and no agents_client provided.""" - # Mock AzureAISettings to return settings with None project_endpoint - with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError, match="project endpoint is required"): - AzureAIAgentClient( - agents_client=None, - agent_id=None, - project_endpoint=None, # Missing endpoint - model_deployment_name="test-model", - credential=AsyncMock(spec=AsyncTokenCredential), - ) - - -def test_azure_ai_chat_client_init_missing_model_deployment_for_agent_creation() -> None: - """Test AzureAIAgentClient initialization when model deployment is missing for agent creation.""" - # Mock AzureAISettings to return settings with None model_deployment_name - with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} - - with pytest.raises(ValueError, match="model deployment name is required"): - AzureAIAgentClient( - agents_client=None, - agent_id=None, # No existing agent - project_endpoint="https://test.com", - model_deployment_name=None, # Missing for agent creation - credential=AsyncMock(spec=AsyncTokenCredential), - ) - - -def test_azure_ai_chat_client_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIAgentClient.__init__ when credential is missing and no agents_client provided.""" - with pytest.raises(ValueError, match="Azure credential is required when agents_client is not provided"): - AzureAIAgentClient( - agents_client=None, - agent_id="existing-agent", - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=None, # Missing credential - ) - - -def test_azure_ai_chat_client_from_dict() -> None: - """Test from_settings class method.""" - mock_agents_client = MagicMock() - settings = { - "agents_client": mock_agents_client, - "agent_id": "test-agent", - "thread_id": "test-thread", - "project_endpoint": "https://test.com", - "model_deployment_name": "test-model", - "agent_name": "TestAgent", - } - - client = AzureAIAgentClient.from_dict(settings) - - assert client.agents_client is mock_agents_client - assert client.agent_id == "test-agent" - assert client.thread_id == "test-thread" - assert client.agent_name == "TestAgent" - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_temperature_and_top_p( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with temperature and top_p in run_options.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - run_options = { - "model": azure_ai_settings.get("model_deployment_name"), - "temperature": 0.7, - "top_p": 0.9, - } - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with temperature and top_p parameters - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["temperature"] == 0.7 - assert call_kwargs["top_p"] == 0.9 - - -async def test_azure_ai_chat_client_get_agent_id_or_create_existing_agent( - mock_agents_client: MagicMock, -) -> None: - """Test _get_agent_id_or_create when agent_id is already provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="existing-agent-id") - - agent_id = await client._get_agent_id_or_create() # type: ignore - - assert agent_id == "existing-agent-id" - assert not client._agent_created - - -async def test_azure_ai_chat_client_get_agent_id_or_create_create_new( - mock_agents_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test _get_agent_id_or_create when creating a new agent.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - chat_client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - agent_id = await chat_client._get_agent_id_or_create( - run_options={"model": azure_ai_settings.get("model_deployment_name")} - ) # type: ignore - - assert agent_id == "test-agent-id" - assert chat_client._agent_created - - -async def test_azure_ai_chat_client_thread_management_through_public_api(mock_agents_client: MagicMock) -> None: - """Test thread creation and management through public API.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock get_agent to avoid the async error - mock_agents_client.get_agent = AsyncMock(return_value=None) - - mock_thread = MagicMock() - mock_thread.id = "new-thread-456" - mock_agents_client.threads.create = AsyncMock(return_value=mock_thread) - - mock_stream = AsyncMock() - mock_agents_client.runs.stream = AsyncMock(return_value=mock_stream) - - # Create an async iterator that yields nothing (empty stream) - async def empty_async_iter(): - return - yield # Make this a generator (unreachable) - - mock_stream.__aenter__ = AsyncMock(return_value=empty_async_iter()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - messages = [Message(role="user", text="Hello")] - - # Call without existing thread - should create new one - response = client.get_response(messages, stream=True) - # Consume the generator to trigger the method execution - async for _ in response: - pass - - # Verify thread creation was called - mock_agents_client.threads.create.assert_called_once() - - -@pytest.mark.parametrize("exclude_list", [["AZURE_AI_MODEL_DEPLOYMENT_NAME"]], indirect=True) -async def test_azure_ai_chat_client_get_agent_id_or_create_missing_model( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create when model_deployment_name is missing.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - with pytest.raises(ValueError, match="Model deployment name is required"): - await client._get_agent_id_or_create() # type: ignore - - -async def test_azure_ai_chat_client_prepare_options_basic(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with basic ChatOptions.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = {"max_tokens": 100, "temperature": 0.7} - - run_options, tool_results = await client._prepare_options(messages, chat_options) # type: ignore - - assert run_options is not None - assert tool_results is None - - -async def test_azure_ai_chat_client_prepare_options_no_chat_options(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with default ChatOptions.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - messages = [Message(role="user", text="Hello")] - - run_options, tool_results = await client._prepare_options(messages, {}) # type: ignore - - assert run_options is not None - assert tool_results is None - - -async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with image content.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock get_agent - mock_agents_client.get_agent = AsyncMock(return_value=None) - - image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg") - messages = [Message(role="user", contents=[image_content])] - - run_options, _ = await client._prepare_options(messages, {}) # type: ignore - - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 - # Verify image was converted to MessageInputImageUrlBlock - message = run_options["additional_messages"][0] - assert len(message.content) == 1 - - -def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_none(mock_agents_client: MagicMock) -> None: - """Test _prepare_tool_outputs_for_azure_ai with None input.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai(None) # type: ignore - - assert run_id is None - assert tool_outputs is None - assert tool_approvals is None - - -async def test_azure_ai_chat_client_close_client_when_should_close_true(mock_agents_client: MagicMock) -> None: - """Test _close_client_if_needed closes agents_client when should_close_client is True.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client._should_close_client = True # type: ignore - - mock_agents_client.close = AsyncMock() - - await client._close_client_if_needed() # type: ignore - - mock_agents_client.close.assert_called_once() - - -async def test_azure_ai_chat_client_close_client_when_should_close_false(mock_agents_client: MagicMock) -> None: - """Test _close_client_if_needed does not close agents_client when should_close_client is False.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client._should_close_client = False # type: ignore - - await client._close_client_if_needed() # type: ignore - - mock_agents_client.close.assert_not_called() - - -def test_azure_ai_chat_client_update_agent_name_and_description_when_current_is_none( - mock_agents_client: MagicMock, -) -> None: - """Test _update_agent_name_and_description updates name when current agent_name is None.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client.agent_name = None # type: ignore - - client._update_agent_name_and_description("NewAgentName", "description") # type: ignore - - assert client.agent_name == "NewAgentName" - assert client.agent_description == "description" - - -def test_azure_ai_chat_client_update_agent_name_and_description_when_current_exists( - mock_agents_client: MagicMock, -) -> None: - """Test _update_agent_name_and_description does not update when current agent_name exists.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client.agent_name = "ExistingName" # type: ignore - client.agent_description = "ExistingDescription" # type: ignore - - client._update_agent_name_and_description("NewAgentName", "description") # type: ignore - - assert client.agent_name == "ExistingName" - assert client.agent_description == "ExistingDescription" - - -def test_azure_ai_chat_client_update_agent_name_and_description_with_none_input(mock_agents_client: MagicMock) -> None: - """Test _update_agent_name_and_description with None input.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client.agent_name = None # type: ignore - client.agent_description = None # type: ignore - - client._update_agent_name_and_description(None, None) # type: ignore - - assert client.agent_name is None - assert client.agent_description is None - - -async def test_azure_ai_chat_client_prepare_options_with_messages(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with different message types.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Test with system message (becomes instruction) - messages = [ - Message(role="system", text="You are a helpful assistant"), - Message(role="user", text="Hello"), - ] - - run_options, _ = await client._prepare_options(messages, {}) # type: ignore - - assert "instructions" in run_options - assert "You are a helpful assistant" in run_options["instructions"] - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 # Only user message - - -async def test_azure_ai_chat_client_prepare_options_with_instructions_from_options( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options includes instructions passed via options. - - This verifies that agent instructions set via as_agent(instructions=...) - are properly included in the API call. - """ - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - mock_agents_client.get_agent = AsyncMock(return_value=None) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = { - "instructions": "You are a thoughtful reviewer. Give brief feedback.", - } - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - assert "instructions" in run_options - assert "reviewer" in run_options["instructions"].lower() - - -async def test_azure_ai_chat_client_prepare_options_merges_instructions_from_messages_and_options( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options merges instructions from both system messages and options. - - When instructions come from both system/developer messages AND from options, - both should be included in the final instructions. - """ - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - mock_agents_client.get_agent = AsyncMock(return_value=None) - - messages = [ - Message(role="system", text="Context: You are reviewing marketing copy."), - Message(role="user", text="Review this tagline"), - ] - chat_options: ChatOptions = { - "instructions": "Be concise and constructive in your feedback.", - } - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - assert "instructions" in run_options - instructions_text = run_options["instructions"] - # Both instruction sources should be present - assert "marketing" in instructions_text.lower() - assert "concise" in instructions_text.lower() - - -def test_as_agent_uses_client_agent_name_as_default(mock_agents_client: MagicMock) -> None: - """Test that as_agent() defaults Agent.name to client.agent_name when name is not provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="my_agent") - client.agent_description = "my description" - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name == "my_agent" - assert agent.description == "my description" - - -def test_as_agent_explicit_name_overrides_client_agent_name(mock_agents_client: MagicMock) -> None: - """Test that an explicit name passed to as_agent() takes precedence over client.agent_name.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.") - - assert agent.name == "explicit_name" - assert agent.description == "explicit description" - - -def test_as_agent_no_name_anywhere(mock_agents_client: MagicMock) -> None: - """Test that Agent.name is None when neither as_agent name nor client.agent_name is provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name is None - - -def test_as_agent_empty_string_preserves_explicit_value(mock_agents_client: MagicMock) -> None: - """Test that empty-string name/description are preserved and do not fall back to client defaults.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="", description="", instructions="You are helpful.") - - assert agent.name == "" - assert agent.description == "" - - -async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: MagicMock) -> None: - """Test _inner_get_response method.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - async def mock_streaming_response(): - yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Hello back")]) - - with ( - patch.object(client, "_inner_get_response", return_value=mock_streaming_response()), - patch("agent_framework.ChatResponse.from_update_generator") as mock_from_generator, - ): - mock_response = ChatResponse(messages=[Message(role="assistant", text="Hello back")]) - mock_from_generator.return_value = mock_response - - result = await ChatResponse.from_update_generator(mock_streaming_response()) - - assert result is mock_response - mock_from_generator.assert_called_once() - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_run_options( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with run_options containing tools and instructions.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - run_options = { - "tools": [{"type": "function", "function": {"name": "test_tool"}}], - "instructions": "Test instructions", - "response_format": {"type": "json_object"}, - "model": azure_ai_settings.get("model_deployment_name"), - } - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with run_options parameters - mock_agents_client.create_agent.assert_called_once() - call_args = mock_agents_client.create_agent.call_args[1] - assert "tools" in call_args - assert "instructions" in call_args - assert "response_format" in call_args - - -async def test_azure_ai_chat_client_prepare_thread_cancels_active_run(mock_agents_client: MagicMock) -> None: - """Test _prepare_thread cancels active thread run when provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - mock_thread_run = MagicMock() - mock_thread_run.id = "run_123" - mock_thread_run.thread_id = "test-thread" - - run_options = {"additional_messages": []} # type: ignore - - result = await client._prepare_thread("test-thread", mock_thread_run, run_options) # type: ignore - - assert result == "test-thread" - mock_agents_client.runs.cancel.assert_called_once_with("test-thread", "run_123") - - -def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_basic(mock_agents_client: MagicMock) -> None: - """Test _parse_function_calls_from_azure_ai with basic function call.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - mock_tool_call = MagicMock(spec=RequiredFunctionToolCall) - mock_tool_call.id = "call_123" - mock_tool_call.function.name = "get_weather" - mock_tool_call.function.arguments = '{"location": "Seattle"}' - - mock_submit_action = MagicMock(spec=SubmitToolOutputsAction) - mock_submit_action.submit_tool_outputs.tool_calls = [mock_tool_call] - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = mock_submit_action - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert len(result) == 1 - assert result[0].type == "function_call" - assert result[0].name == "get_weather" - assert result[0].call_id == '["response_123", "call_123"]' - - -def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_no_submit_action( - mock_agents_client: MagicMock, -) -> None: - """Test _parse_function_calls_from_azure_ai when required_action is not SubmitToolOutputsAction.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = MagicMock() - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert result == [] - - -def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_non_function_tool_call( - mock_agents_client: MagicMock, -) -> None: - """Test _parse_function_calls_from_azure_ai with non-function tool call.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - mock_tool_call = MagicMock() - - mock_submit_action = MagicMock(spec=SubmitToolOutputsAction) - mock_submit_action.submit_tool_outputs.tool_calls = [mock_tool_call] - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = mock_submit_action - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert result == [] - - -async def test_azure_ai_chat_client_prepare_options_with_none_tool_choice( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with tool_choice set to 'none'.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - chat_options: ChatOptions = {"tool_choice": "none"} - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.NONE - - -async def test_azure_ai_chat_client_prepare_options_with_auto_tool_choice( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with tool_choice set to 'auto'.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - chat_options = {"tool_choice": "auto"} - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.AUTO - - -async def test_azure_ai_chat_client_prepare_options_tool_choice_required_specific_function( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with required tool_choice specifying a specific function name.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - required_tool_mode = {"mode": "required", "required_function_name": "specific_function_name"} - - dict_tool = {"type": "function", "function": {"name": "test_function"}} - - chat_options = {"tools": [dict_tool], "tool_choice": required_tool_mode} - messages = [Message(role="user", text="Hello")] - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - # Verify tool_choice is set to the specific named function - assert "tool_choice" in run_options - tool_choice = run_options["tool_choice"] - assert isinstance(tool_choice, AgentsNamedToolChoice) - assert tool_choice.type == AgentsNamedToolChoiceType.FUNCTION - assert tool_choice.function.name == "specific_function_name" # type: ignore - - -async def test_azure_ai_chat_client_prepare_options_with_response_format( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with response_format configured.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - class TestResponseModel(BaseModel): - name: str = Field(description="Test name") - - chat_options: ChatOptions = {"response_format": TestResponseModel} - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert "response_format" in run_options - response_format = run_options["response_format"] - assert response_format.json_schema.name == "TestResponseModel" - - -def test_azure_ai_chat_client_service_url_method(mock_agents_client: MagicMock) -> None: - """Test service_url method returns endpoint.""" - mock_agents_client._config.endpoint = "https://test-endpoint.com/" - client = create_test_azure_ai_chat_client(mock_agents_client) - - url = client.service_url() - assert url == "https://test-endpoint.com/" - - -async def test_azure_ai_chat_client_prepare_options_mcp_never_require(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with MCP dict tool having never_require approval mode.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Create MCP tool with approval_mode parameter - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP Tool", url="https://example.com/mcp", approval_mode="never_require" - ) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"} - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - # Verify tool_resources is created with correct MCP approval structure - assert "tool_resources" in run_options, f"Expected 'tool_resources' in run_options keys: {list(run_options.keys())}" - assert "mcp" in run_options["tool_resources"] - assert len(run_options["tool_resources"]["mcp"]) == 1 - - mcp_resource = run_options["tool_resources"]["mcp"][0] - assert mcp_resource["server_label"] == "Test_MCP_Tool" - assert mcp_resource["require_approval"] == "never" - - -async def test_azure_ai_chat_client_prepare_options_mcp_with_headers(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with MCP dict tool having headers.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Test with headers - create MCP tool with all options - headers = {"Authorization": "Bearer DUMMY_TOKEN", "X-API-Key": "DUMMY_KEY"} - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP Tool", - url="https://example.com/mcp", - headers=headers, - approval_mode="never_require", - ) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"} - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - # Verify tool_resources is created with headers - assert "tool_resources" in run_options - assert "mcp" in run_options["tool_resources"] - assert len(run_options["tool_resources"]["mcp"]) == 1 - - mcp_resource = run_options["tool_resources"]["mcp"][0] - assert mcp_resource["server_label"] == "Test_MCP_Tool" - assert mcp_resource["require_approval"] == "never" - assert mcp_resource["headers"] == headers - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with BingGroundingTool from get_web_search_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock BingGroundingTool to avoid SDK validation of connection ID - with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: - mock_bing_tool = MagicMock() - mock_bing_tool.definitions = [{"type": "bing_grounding"}] - mock_bing_grounding.return_value = mock_bing_tool - - # get_web_search_tool now returns a BingGroundingTool directly - web_search_tool = client.get_web_search_tool(bing_connection_id="test-connection-id") - - # Verify the factory method created the tool with correct args - mock_bing_grounding.assert_called_once_with(connection_id="test-connection-id") - - result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore - - # BingGroundingTool.definitions should be extended into result - assert len(result) == 1 - assert result[0] == {"type": "bing_grounding"} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding_with_connection_id( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with BingGroundingTool using explicit connection_id.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock BingGroundingTool to avoid SDK validation of connection ID - with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: - mock_bing_tool = MagicMock() - mock_bing_tool.definitions = [{"type": "bing_grounding"}] - mock_bing_grounding.return_value = mock_bing_tool - - web_search_tool = client.get_web_search_tool(bing_connection_id="direct-connection-id") - - mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id") - - result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "bing_grounding"} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_custom_bing( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with BingCustomSearchTool from get_web_search_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock BingCustomSearchTool to avoid SDK validation - with patch("agent_framework_azure_ai._chat_client.BingCustomSearchTool") as mock_custom_bing: - mock_custom_tool = MagicMock() - mock_custom_tool.definitions = [{"type": "bing_custom_search"}] - mock_custom_bing.return_value = mock_custom_tool - - web_search_tool = client.get_web_search_tool( - bing_custom_connection_id="custom-connection-id", - bing_custom_instance_id="custom-instance", - ) - - mock_custom_bing.assert_called_once_with( - connection_id="custom-connection-id", - instance_name="custom-instance", - ) - - result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "bing_custom_search"} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_file_search_with_vector_stores( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with FileSearchTool from get_file_search_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # get_file_search_tool() now returns a FileSearchTool instance directly - file_search_tool = client.get_file_search_tool(vector_store_ids=["vs-123"]) - - run_options: dict[str, Any] = {} - result = await client._prepare_tools_for_azure_ai([file_search_tool], run_options) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "file_search"} - assert run_options["tool_resources"] == {"file_search": {"vector_store_ids": ["vs-123"]}} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_code_interpreter_with_file_ids( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with CodeInterpreterTool with file_ids from get_code_interpreter_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - code_interpreter_tool = client.get_code_interpreter_tool(file_ids=["file-123", "file-456"]) - - run_options: dict[str, Any] = {} - result = await client._prepare_tools_for_azure_ai([code_interpreter_tool], run_options) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - assert "tool_resources" in run_options - assert "code_interpreter" in run_options["tool_resources"] - assert sorted(run_options["tool_resources"]["code_interpreter"]["file_ids"]) == ["file-123", "file-456"] - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_basic() -> None: - """Test get_code_interpreter_tool returns CodeInterpreterTool without files.""" - from azure.ai.agents.models import CodeInterpreterTool - - tool = AzureAIAgentClient.get_code_interpreter_tool() - assert isinstance(tool, CodeInterpreterTool) - assert len(tool.file_ids) == 0 - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_file_ids() -> None: - """Test get_code_interpreter_tool forwards file_ids to the SDK.""" - from azure.ai.agents.models import CodeInterpreterTool - - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-abc", "file-def"]) - assert isinstance(tool, CodeInterpreterTool) - assert "file-abc" in tool.file_ids - assert "file-def" in tool.file_ids - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_data_sources() -> None: - """Test get_code_interpreter_tool forwards data_sources to the SDK.""" - from azure.ai.agents.models import CodeInterpreterTool, VectorStoreDataSource - - ds = VectorStoreDataSource(asset_identifier="test-asset-id", asset_type="id_asset") - tool = AzureAIAgentClient.get_code_interpreter_tool(data_sources=[ds]) - assert isinstance(tool, CodeInterpreterTool) - assert "test-asset-id" in tool.data_sources - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_mutually_exclusive() -> None: - """Test get_code_interpreter_tool raises ValueError when both file_ids and data_sources are provided.""" - from azure.ai.agents.models import VectorStoreDataSource - - ds = VectorStoreDataSource(asset_identifier="test-asset-id", asset_type="id_asset") - with pytest.raises(ValueError, match="mutually exclusive"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-abc"], data_sources=[ds]) - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_content() -> None: - """Test get_code_interpreter_tool accepts Content.from_hosted_file in file_ids.""" - from agent_framework import Content - from azure.ai.agents.models import CodeInterpreterTool - - content = Content.from_hosted_file("file-content-123") - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=[content]) - assert isinstance(tool, CodeInterpreterTool) - assert "file-content-123" in tool.file_ids - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_mixed_file_ids() -> None: - """Test get_code_interpreter_tool accepts a mix of strings and Content objects.""" - from agent_framework import Content - from azure.ai.agents.models import CodeInterpreterTool - - content = Content.from_hosted_file("file-from-content") - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-plain", content]) - assert isinstance(tool, CodeInterpreterTool) - assert "file-plain" in tool.file_ids - assert "file-from-content" in tool.file_ids - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_content_unsupported_type() -> None: - """Test get_code_interpreter_tool raises ValueError for unsupported Content types.""" - from agent_framework import Content - - content = Content.from_hosted_vector_store("vs-123") - with pytest.raises(ValueError, match="Unsupported Content type"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=[content]) - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_content_missing_file_id() -> None: - """Test get_code_interpreter_tool raises ValueError when Content.file_id is None.""" - from agent_framework import Content - - content = Content(type="hosted_file") - with pytest.raises(ValueError, match="missing a file_id"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=[content]) - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_empty_string_file_id() -> None: - """Test get_code_interpreter_tool raises ValueError for empty string file_ids.""" - with pytest.raises(ValueError, match="must not contain empty strings"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=[""]) - - -async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals( - mock_agents_client: MagicMock, -) -> None: - """Test _create_agent_stream with tool approvals submission path.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock active thread run that matches the tool run ID - mock_thread_run = MagicMock() - mock_thread_run.thread_id = "test-thread" - mock_thread_run.id = "test-run-id" - client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore - - # Mock required action results with approval response that matches run ID - approval_response = Content.from_function_approval_response( - id='["test-run-id", "test-call-id"]', - function_call=Content.from_function_call( - call_id='["test-run-id", "test-call-id"]', name="test_function", arguments="{}" - ), - approved=True, - ) - - # Mock submit_tool_outputs_stream - mock_handler = MagicMock() - mock_agents_client.runs.submit_tool_outputs_stream = AsyncMock() - - with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler): - stream, final_thread_id = await client._create_agent_stream( # type: ignore - "test-agent", {"thread_id": "test-thread"}, [approval_response] - ) - - # Verify the approvals path was taken - assert final_thread_id == "test-thread" - - # Verify submit_tool_outputs_stream was called with approvals - mock_agents_client.runs.submit_tool_outputs_stream.assert_called_once() - call_args = mock_agents_client.runs.submit_tool_outputs_stream.call_args[1] - assert "tool_approvals" in call_args - assert call_args["tool_approvals"][0].tool_call_id == "test-call-id" - assert call_args["tool_approvals"][0].approve is True - - -async def test_azure_ai_chat_client_get_active_thread_run_with_active_run(mock_agents_client: MagicMock) -> None: - """Test _get_active_thread_run when there's an active run.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock an active run - mock_run = MagicMock() - mock_run.status = RunStatus.IN_PROGRESS - - async def mock_list_runs(*args, **kwargs): # type: ignore - yield mock_run - - mock_agents_client.runs.list = mock_list_runs - - result = await client._get_active_thread_run("thread-123") # type: ignore - - assert result == mock_run - - -async def test_azure_ai_chat_client_get_active_thread_run_no_active_run(mock_agents_client: MagicMock) -> None: - """Test _get_active_thread_run when there's no active run.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock a completed run (not active) - mock_run = MagicMock() - mock_run.status = RunStatus.COMPLETED - - async def mock_list_runs(*args, **kwargs): # type: ignore - yield mock_run - - mock_agents_client.runs.list = mock_list_runs - - result = await client._get_active_thread_run("thread-123") # type: ignore - - assert result is None - - -async def test_azure_ai_chat_client_get_active_thread_run_no_thread(mock_agents_client: MagicMock) -> None: - """Test _get_active_thread_run with None thread_id.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - result = await client._get_active_thread_run(None) # type: ignore - - assert result is None - # Should not call list since thread_id is None - mock_agents_client.runs.list.assert_not_called() - - -async def test_azure_ai_chat_client_service_url(mock_agents_client: MagicMock) -> None: - """Test service_url method.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock the config endpoint - mock_config = MagicMock() - mock_config.endpoint = "https://test-endpoint.com/" - mock_agents_client._config = mock_config - - result = client.service_url() - - assert result == "https://test-endpoint.com/" - - -async def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_tool_result( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with FunctionResultContent.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with simple result - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="Simple result") - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - assert run_id == "run_123" - assert tool_approvals is None - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].tool_call_id == "call_456" - assert tool_outputs[0].output == "Simple result" - - -async def test_azure_ai_chat_client_convert_required_action_invalid_call_id(mock_agents_client: MagicMock) -> None: - """Test _prepare_tool_outputs_for_azure_ai with invalid call_id format.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Invalid call_id format - should raise JSONDecodeError - function_result = Content.from_function_result(call_id="invalid_json", result="result") - - with pytest.raises(json.JSONDecodeError): - client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - -async def test_azure_ai_chat_client_convert_required_action_invalid_structure( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with invalid call_id structure.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Valid JSON but invalid structure (missing second element) - function_result = Content.from_function_result(call_id='["run_123"]', result="result") - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - # Should return None values when structure is invalid - assert run_id is None - assert tool_outputs is None - assert tool_approvals is None - - -async def test_azure_ai_chat_client_convert_required_action_serde_model_results( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with BaseModel results.""" - - class MockResult(SerializationMixin): - def __init__(self, name: str, value: int): - self.name = name - self.value = value - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with BaseModel result (pre-parsed as it would be from FunctionTool.invoke) - mock_result = MockResult(name="test", value=42) - expected_json = mock_result.to_json() - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=expected_json) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - assert run_id == "run_123" - assert tool_approvals is None - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].tool_call_id == "call_456" - # Should use pre-parsed result string directly - assert tool_outputs[0].output == expected_json - - -async def test_azure_ai_chat_client_convert_required_action_multiple_results( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with multiple results.""" - - class MockResult(SerializationMixin): - def __init__(self, data: str): - self.data = data - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with multiple results - pre-parsed as FunctionTool.invoke would produce - mock_basemodel = MockResult(data="model_data") - results_list = [mock_basemodel, {"key": "value"}, "string_result"] - # FunctionTool.parse_result would serialize this to a JSON string - from agent_framework import FunctionTool - - pre_parsed = FunctionTool.parse_result(results_list) - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=pre_parsed) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - assert run_id == "run_123" - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].tool_call_id == "call_456" - - # Result is the text content extracted from items - assert tool_outputs[0].output == function_result.result - - -async def test_azure_ai_chat_client_convert_required_action_approval_response( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with FunctionApprovalResponseContent.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with approval response - need to provide required fields - approval_response = Content.from_function_approval_response( - id='["run_123", "call_456"]', - function_call=Content.from_function_call( - call_id='["run_123", "call_456"]', name="test_function", arguments="{}" - ), - approved=True, - ) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([approval_response]) # type: ignore - - assert run_id == "run_123" - assert tool_outputs is None - assert tool_approvals is not None - assert len(tool_approvals) == 1 - assert tool_approvals[0].tool_call_id == "call_456" - assert tool_approvals[0].approve is True - - -async def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_approval_request( - mock_agents_client: MagicMock, -) -> None: - """Test _parse_function_calls_from_azure_ai with approval action.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock SubmitToolApprovalAction with RequiredMcpToolCall - mock_tool_call = MagicMock(spec=RequiredMcpToolCall) - mock_tool_call.id = "approval_call_123" - mock_tool_call.name = "approve_action" - mock_tool_call.arguments = '{"action": "approve"}' - - mock_approval_action = MagicMock(spec=SubmitToolApprovalAction) - mock_approval_action.submit_tool_approval.tool_calls = [mock_tool_call] - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = mock_approval_action - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert len(result) == 1 - assert result[0].type == "function_approval_request" - assert result[0].id == '["response_123", "approval_call_123"]' - assert result[0].function_call.name == "approve_action" - assert result[0].function_call.call_id == '["response_123", "approval_call_123"]' - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_agent_name( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create uses default name when no agent_name set.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - # Ensure agent_name is None to test the default - client.agent_name = None # type: ignore - - agent_id = await client._get_agent_id_or_create( - run_options={"model": azure_ai_settings.get("model_deployment_name")} - ) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with default "UnnamedAgent" - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["name"] == "UnnamedAgent" - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_response_format( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with response_format in run_options.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - # Test with response_format in run_options - run_options = {"response_format": {"type": "json_object"}, "model": azure_ai_settings.get("model_deployment_name")} - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with response_format - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["response_format"] == {"type": "json_object"} - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_tool_resources( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with tool_resources in run_options.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - # Test with tool_resources in run_options - run_options = { - "tool_resources": {"vector_store_ids": ["vs-123"]}, - "model": azure_ai_settings.get("model_deployment_name"), - } - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with tool_resources - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["tool_resources"] == {"vector_store_ids": ["vs-123"]} - - -async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs( - mock_agents_client: MagicMock, -) -> None: - """Test _create_agent_stream with tool outputs submission path.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock active thread run that matches the tool run ID - mock_thread_run = MagicMock() - mock_thread_run.thread_id = "test-thread" - mock_thread_run.id = "test-run-id" - client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore - - # Mock required action results with matching run ID - function_result = Content.from_function_result(call_id='["test-run-id", "test-call-id"]', result="test result") - - # Mock submit_tool_outputs_stream - mock_handler = MagicMock() - mock_agents_client.runs.submit_tool_outputs_stream = AsyncMock() - - with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler): - stream, final_thread_id = await client._create_agent_stream( # type: ignore - agent_id="test-agent", run_options={"thread_id": "test-thread"}, required_action_results=[function_result] - ) - - # Should call submit_tool_outputs_stream since we have matching run ID - mock_agents_client.runs.submit_tool_outputs_stream.assert_called_once() - assert final_thread_id == "test-thread" - - -def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_client: MagicMock) -> None: - """Test _extract_url_citations with MessageDeltaChunk containing URL citations.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock URL citation annotation - mock_url_citation = MagicMock() - mock_url_citation.url = "https://example.com/test" - mock_url_citation.title = "Test Title" - - mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation) - mock_annotation.url_citation = mock_url_citation - mock_annotation.start_index = 10 - mock_annotation.end_index = 20 - - # Create mock text content with annotations - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method with empty azure_search_tool_calls - citations = client._extract_url_citations(mock_chunk, []) # type: ignore - - # Verify results - assert len(citations) == 1 - citation = citations[0] - assert citation["url"] == "https://example.com/test" - assert citation["title"] == "Test Title" - assert citation["snippet"] is None - assert citation["annotated_regions"] is not None - assert len(citation["annotated_regions"]) == 1 - assert citation["annotated_regions"][0]["start_index"] == 10 - assert citation["annotated_regions"][0]["end_index"] == 20 - - -def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotation( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_file_path_contents with MessageDeltaChunk containing file path annotation.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock file_path annotation - mock_file_path = MagicMock() - mock_file_path.file_id = "assistant-test-file-123" - - mock_annotation = MagicMock(spec=MessageDeltaTextFilePathAnnotation) - mock_annotation.file_path = mock_file_path - - # Create mock text content with annotations - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method - file_contents = client._extract_file_path_contents(mock_chunk) - - # Verify results - assert len(file_contents) == 1 - assert file_contents[0].type == "hosted_file" - assert file_contents[0].file_id == "assistant-test-file-123" - - -def test_azure_ai_chat_client_extract_file_path_contents_with_file_citation_annotation( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_file_path_contents with MessageDeltaChunk containing file citation annotation.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock file_citation annotation - mock_file_citation = MagicMock() - mock_file_citation.file_id = "cfile_test-citation-456" - - mock_annotation = MagicMock(spec=MessageDeltaTextFileCitationAnnotation) - mock_annotation.file_citation = mock_file_citation - - # Create mock text content with annotations - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method - file_contents = client._extract_file_path_contents(mock_chunk) - - # Verify results - assert len(file_contents) == 1 - assert file_contents[0].type == "hosted_file" - assert file_contents[0].file_id == "cfile_test-citation-456" - - -def test_azure_ai_chat_client_extract_file_path_contents_empty_annotations( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_file_path_contents with no annotations returns empty list.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock text content with no annotations - mock_text = MagicMock() - mock_text.annotations = [] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method - file_contents = client._extract_file_path_contents(mock_chunk) - - # Verify results - assert len(file_contents) == 0 - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -async def test_azure_ai_chat_client_cleanup_agent_when_enabled_and_created( - mock_agents_client: MagicMock, -) -> None: - """Test that agent is cleaned up when should_cleanup_agent=True and agent was created by client.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=True) - - # Simulate agent creation - client.agent_id = "created-agent-id" - client._agent_created = True # type: ignore - - await client._cleanup_agent_if_needed() # type: ignore - - # Verify agent was deleted - mock_agents_client.delete_agent.assert_called_once_with("created-agent-id") - assert client.agent_id is None - assert client._agent_created is False # type: ignore - - -async def test_azure_ai_chat_client_no_cleanup_when_disabled( - mock_agents_client: MagicMock, -) -> None: - """Test that agent is not cleaned up when should_cleanup_agent=False.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=False) - - # Simulate agent creation - client.agent_id = "created-agent-id" - client._agent_created = True - - await client._cleanup_agent_if_needed() # type: ignore - - # Verify agent was NOT deleted - mock_agents_client.delete_agent.assert_not_called() - assert client.agent_id == "created-agent-id" - assert client._agent_created is True - - -async def test_azure_ai_chat_client_no_cleanup_when_agent_not_created_by_client( - mock_agents_client: MagicMock, -) -> None: - """Test that agent is not cleaned up when it was not created by this client instance.""" - client = create_test_azure_ai_chat_client( - mock_agents_client, agent_id="existing-agent-id", should_cleanup_agent=True - ) - - # Agent exists but was not created by this client (_agent_created = False) - assert client._agent_created is False # type: ignore - - await client._cleanup_agent_if_needed() # type: ignore - - # Verify agent was NOT deleted - mock_agents_client.delete_agent.assert_not_called() - assert client.agent_id == "existing-agent-id" - - -def test_azure_ai_chat_client_capture_azure_search_tool_calls(mock_agents_client: MagicMock) -> None: - """Test _capture_azure_search_tool_calls method.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Mock Azure AI Search tool call - mock_tool_call = MagicMock() - mock_tool_call.type = "azure_ai_search" - mock_tool_call.id = "call_123" - mock_tool_call.azure_ai_search = {"input": "test query", "output": "test output"} - - # Mock step data - mock_step_data = MagicMock() - mock_step_data.step_details.tool_calls = [mock_tool_call] - - # Call the method with a list to capture tool calls - azure_search_tool_calls: list[dict[str, Any]] = [] - client._capture_azure_search_tool_calls(mock_step_data, azure_search_tool_calls) # type: ignore - - # Verify tool call was captured - assert len(azure_search_tool_calls) == 1 - captured_tool_call = azure_search_tool_calls[0] - assert captured_tool_call["type"] == "azure_ai_search" - assert captured_tool_call["id"] == "call_123" - assert captured_tool_call["azure_ai_search"] == {"input": "test query", "output": "test output"} - - -def test_azure_ai_chat_client_get_real_url_from_citation_reference_no_tool_calls( - mock_agents_client: MagicMock, -) -> None: - """Test _get_real_url_from_citation_reference with no tool calls.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # No tool calls - pass empty list - result = client._get_real_url_from_citation_reference("doc_1", []) # type: ignore - assert result == "doc_1" - - -def test_azure_ai_chat_client_get_real_url_from_citation_reference_invalid_output( - mock_agents_client: MagicMock, -) -> None: - """Test _get_real_url_from_citation_reference with invalid output format.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Tool call with invalid output format - azure_search_tool_calls = [ - {"id": "call_123", "type": "azure_ai_search", "azure_ai_search": {"output": "invalid_json_format"}} - ] - - result = client._get_real_url_from_citation_reference("doc_1", azure_search_tool_calls) # type: ignore - assert result == "doc_1" - - -async def test_azure_ai_chat_client_context_manager(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentClient as async context manager.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Mock close method to avoid actual cleanup - client.close = AsyncMock() - - async with client as client: - assert client is client - - # Verify close was called on exit - client.close.assert_called_once() - - -async def test_azure_ai_chat_client_close_method(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentClient close method.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Mock cleanup methods - client._cleanup_agent_if_needed = AsyncMock() - client._close_client_if_needed = AsyncMock() - - await client.close() - - # Verify cleanup methods were called - client._cleanup_agent_if_needed.assert_called_once() - client._close_client_if_needed.assert_called_once() - - -def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_url( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_url_citations with Azure AI Search URL enhancement.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Add Azure Search tool calls for URL enhancement - azure_search_tool_calls = [ - { - "id": "call_123", - "type": "azure_ai_search", - "azure_ai_search": { - "output": str({ - "metadata": {"get_urls": ["https://real-example.com/doc1", "https://real-example.com/doc2"]} - }) - }, - } - ] - - # Create mock URL citation with doc reference - mock_url_citation = MagicMock() - mock_url_citation.url = "doc_1" - mock_url_citation.title = "Test Title" - - mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation) - mock_annotation.url_citation = mock_url_citation - mock_annotation.start_index = 10 - mock_annotation.end_index = 20 - - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - citations = client._extract_url_citations(mock_chunk, azure_search_tool_calls) # type: ignore - - # Verify real URL was used - assert len(citations) == 1 - citation = citations[0] - assert citation["url"] == "https://real-example.com/doc2" # doc_1 maps to index 1 - - -def test_azure_ai_chat_client_init_with_auto_created_agents_client( - azure_ai_unit_test_env: dict[str, str], mock_azure_credential: MagicMock -) -> None: - """Test AzureAIAgentClient initialization when it creates its own AgentsClient.""" - - # Mock the AgentsClient constructor - with patch("agent_framework_azure_ai._chat_client.AgentsClient") as mock_agents_client_class: - mock_agents_client_instance = MagicMock() - mock_agents_client_class.return_value = mock_agents_client_instance - - # Create client without providing agents_client - should create its own - client = AzureAIAgentClient( - agents_client=None, # This will trigger creation of AgentsClient - agent_id="test-agent", - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=mock_azure_credential, - ) - - # Verify AgentsClient was created with correct parameters - mock_agents_client_class.assert_called_once_with( - endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - credential=mock_azure_credential, - user_agent="agent-framework-python/0.0.0", - ) - - # Verify client properties are set correctly - assert client.agents_client is mock_agents_client_instance - assert client.agent_id == "test-agent" - assert client.credential is mock_azure_credential - assert client._should_close_client is True # Should close since we created it # type: ignore[attr-defined] - - -async def test_azure_ai_chat_client_prepare_options_with_mapping_response_format( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with Mapping-based response_format (runtime JSON schema).""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Runtime JSON schema dict - response_format_dict = { - "type": "json_schema", - "json_schema": { - "name": "TestSchema", - "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, - }, - } - - chat_options: ChatOptions = {"response_format": response_format_dict} # type: ignore[typeddict-item] - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert "response_format" in run_options - # Should pass through as-is for Mapping types - assert run_options["response_format"] == response_format_dict - - -async def test_azure_ai_chat_client_prepare_options_with_invalid_response_format( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with invalid response_format raises error.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Invalid response_format (not BaseModel or Mapping) - chat_options: ChatOptions = {"response_format": "invalid_format"} # type: ignore[typeddict-item] - - with pytest.raises(ChatClientInvalidRequestException, match="response_format must be a Pydantic BaseModel"): - await client._prepare_options([], chat_options) # type: ignore - - -async def test_azure_ai_chat_client_prepare_tool_definitions_with_agent_tool_resources( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_definitions_and_resources copies tool_resources from agent definition.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock agent definition with tool_resources - mock_agent_definition = MagicMock() - mock_agent_definition.tools = [] - mock_agent_definition.tool_resources = {"code_interpreter": {"file_ids": ["file-123"]}} - - run_options: dict[str, Any] = {} - options: dict[str, Any] = {} - - await client._prepare_tool_definitions_and_resources(options, mock_agent_definition, run_options) # type: ignore - - # Verify tool_resources was copied to run_options - assert "tool_resources" in run_options - assert run_options["tool_resources"] == {"code_interpreter": {"file_ids": ["file-123"]}} - - -def test_azure_ai_chat_client_prepare_mcp_resources_with_dict_approval_mode( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_mcp_resources with dict-based approval mode (always_require_approval).""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # MCP tool with dict-based approval mode - use approval_mode parameter - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode={"always_require_approval": ["tool1", "tool2"]}, - ) - - result = client._prepare_mcp_resources([mcp_tool]) # type: ignore - - assert len(result) == 1 - assert result[0]["server_label"] == "Test_MCP" - assert "require_approval" in result[0] - - -def test_azure_ai_chat_client_prepare_mcp_resources_with_never_require_dict( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_mcp_resources with dict-based approval mode (never_require_approval).""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # MCP tool with never require approval - use approval_mode parameter - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode={"never_require_approval": ["safe_tool"]}, - ) - - result = client._prepare_mcp_resources([mcp_tool]) # type: ignore - - assert len(result) == 1 - assert "require_approval" in result[0] - - -def test_azure_ai_chat_client_prepare_messages_with_function_result( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_messages extracts function_result content.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="test result") - messages = [Message(role="user", contents=[function_result])] - - additional_messages, instructions, required_action_results = client._prepare_messages(messages) # type: ignore - - # function_result should be extracted, not added to additional_messages - assert additional_messages is None - assert required_action_results is not None - assert len(required_action_results) == 1 - assert required_action_results[0].type == "function_result" - - -def test_azure_ai_chat_client_prepare_messages_with_raw_content_block( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_messages handles raw MessageInputContentBlock in content.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Create content with raw_representation that is a MessageInputContentBlock - raw_block = MessageInputTextBlock(text="Raw block text") - custom_content = Content(type="custom", raw_representation=raw_block) - messages = [Message(role="user", contents=[custom_content])] - - additional_messages, instructions, required_action_results = client._prepare_messages(messages) # type: ignore - - assert additional_messages is not None - assert len(additional_messages) == 1 - assert len(additional_messages[0].content) == 1 - assert additional_messages[0].content[0] == raw_block - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_mcp_tool( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with MCP dict tool.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP Server", - url="https://example.com/mcp", - ) - - tool_definitions = await client._prepare_tools_for_azure_ai([mcp_tool]) # type: ignore - - assert len(tool_definitions) >= 1 - # The McpTool.definitions property returns the tool definitions - # Verify the MCP tool was converted correctly by checking the definition type - mcp_def = tool_definitions[0] - assert mcp_def.get("type") == "mcp" - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_tool_definition( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with ToolDefinition passthrough.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Pass a ToolDefinition directly - should be passed through as-is - tool_def = CodeInterpreterToolDefinition() - - tool_definitions = await client._prepare_tools_for_azure_ai([tool_def]) # type: ignore - - assert len(tool_definitions) == 1 - assert tool_definitions[0] is tool_def - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_dict_passthrough( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with dict passthrough.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Pass a dict tool definition - should be passed through as-is - dict_tool = {"type": "function", "function": {"name": "test_func", "parameters": {}}} - - tool_definitions = await client._prepare_tools_for_azure_ai([dict_tool]) # type: ignore - - assert len(tool_definitions) == 1 - assert tool_definitions[0] is dict_tool - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_unsupported_type( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai passes through unsupported tool types.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Pass an unsupported tool type - it should be passed through unchanged - class UnsupportedTool: - pass - - unsupported_tool = UnsupportedTool() - - # Unsupported tools are now passed through unchanged (server will reject if invalid) - tool_definitions = await client._prepare_tools_for_azure_ai([unsupported_tool]) # type: ignore - assert len(tool_definitions) == 1 - assert tool_definitions[0] is unsupported_tool diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py deleted file mode 100644 index f3e459d0a4..0000000000 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ /dev/null @@ -1,1965 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -import os -import sys -import warnings -from collections.abc import AsyncGenerator, AsyncIterator -from contextlib import asynccontextmanager -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock, patch -from uuid import uuid4 - -import pytest -from agent_framework import ( - Annotation, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - ResponseStream, - SupportsChatGetResponse, - tool, -) -from agent_framework._settings import load_settings -from agent_framework_openai._chat_client import RawOpenAIChatClient -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import ( - ApproximateLocation, - AutoCodeInterpreterToolParam, - CodeInterpreterTool, - FileSearchTool, - ImageGenTool, - MCPTool, - TextResponseFormatJsonSchema, - WebSearchPreviewTool, -) -from azure.core.exceptions import ResourceNotFoundError -from azure.identity.aio import AzureCliCredential -from openai.types.responses.parsed_response import ParsedResponse -from openai.types.responses.response import Response as OpenAIResponse -from pydantic import BaseModel, ConfigDict, Field -from pytest import fixture - -from agent_framework_azure_ai import AzureAIClient, AzureAISettings # noqa: E402 -from agent_framework_azure_ai._shared import from_azure_ai_tools # noqa: E402 - -warnings.filterwarnings( - "ignore", - message=r"RawAzureAIClient is deprecated\..*", - category=DeprecationWarning, -) -warnings.filterwarnings( - "ignore", - message=r"AzureAIClient is deprecated\..*", - category=DeprecationWarning, -) - -pytestmark = pytest.mark.filterwarnings("ignore:AzureAIClient is deprecated\\..*:DeprecationWarning") - - -@pytest.fixture -def mock_project_client() -> MagicMock: - """Fixture that provides a mock AIProjectClient.""" - mock_client = MagicMock() - - # Mock agents property - mock_client.agents = MagicMock() - mock_client.agents.create_version = AsyncMock() - - # Mock conversations property - mock_client.conversations = MagicMock() - mock_client.conversations.create = AsyncMock() - - # Mock telemetry property - mock_client.telemetry = MagicMock() - mock_client.telemetry.get_application_insights_connection_string = AsyncMock() - - # Mock get_openai_client method - mock_client.get_openai_client = AsyncMock() - - # Mock close method - mock_client.close = AsyncMock() - - return mock_client - - -@asynccontextmanager -async def temporary_chat_client(agent_name: str) -> AsyncIterator[AzureAIClient]: - """Async context manager that creates an Azure AI agent and yields an `AzureAIClient`. - - The underlying agent version is cleaned up automatically after use. - Tests can construct their own `Agent` instances from the yielded client. - """ - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential) as project_client, - ): - client = AzureAIClient( - project_client=project_client, - agent_name=agent_name, - ) - try: - yield client - finally: - await project_client.agents.delete(agent_name=agent_name) - - -def create_test_azure_ai_client( - mock_project_client: MagicMock, - agent_name: str | None = None, - agent_version: str | None = None, - conversation_id: str | None = None, - azure_ai_settings: AzureAISettings | None = None, - should_close_client: bool = False, - use_latest_version: bool | None = None, -) -> AzureAIClient: - """Helper function to create AzureAIClient instances for testing, bypassing normal validation.""" - if azure_ai_settings is None: - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - # Create client instance directly - client = object.__new__(AzureAIClient) - - # Set attributes directly - client.project_client = mock_project_client - client.credential = None - client.agent_name = agent_name - client.agent_version = agent_version - client.agent_description = None - client.use_latest_version = use_latest_version - client.model_id = azure_ai_settings.get("model_deployment_name") - client.conversation_id = conversation_id - client._is_application_endpoint = False # type: ignore - client._should_close_client = should_close_client # type: ignore - client.warn_runtime_tools_and_structure_changed = False # type: ignore - client._created_agent_tool_names = set() # type: ignore - client._created_agent_structured_output_signature = None # type: ignore - client.additional_properties = {} - client.chat_middleware = [] - - # Mock the OpenAI client attribute - mock_openai_client = MagicMock() - mock_openai_client.conversations = MagicMock() - mock_openai_client.conversations.create = AsyncMock() - client.client = mock_openai_client - - return client - - -def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAISettings initialization.""" - settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - assert settings["project_endpoint"] == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - assert settings["model_deployment_name"] == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - - -def test_azure_ai_settings_init_with_explicit_values() -> None: - """Test AzureAISettings initialization with explicit values.""" - settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint="https://custom-endpoint.com/", - model_deployment_name="custom-model", - ) - - assert settings["project_endpoint"] == "https://custom-endpoint.com/" - assert settings["model_deployment_name"] == "custom-model" - - -def test_init_with_project_client(mock_project_client: MagicMock) -> None: - """Test AzureAIClient initialization with existing project_client.""" - with patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - client = AzureAIClient( - project_client=mock_project_client, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.project_client is mock_project_client - assert client.agent_name == "test-agent" - assert client.agent_version == "1.0" - assert not client._should_close_client # type: ignore - assert isinstance(client, SupportsChatGetResponse) - - -def test_init_auto_create_client( - azure_ai_unit_test_env: dict[str, str], - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIClient initialization with auto-created project_client.""" - with patch("agent_framework_azure_ai._client.AIProjectClient") as mock_ai_project_client: - mock_project_client = MagicMock() - mock_ai_project_client.return_value = mock_project_client - - client = AzureAIClient( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=mock_azure_credential, - agent_name="test-agent", - ) - - assert client.project_client is mock_project_client - assert client.agent_name == "test-agent" - assert client._should_close_client # type: ignore - - # Verify AIProjectClient was called with correct parameters - mock_ai_project_client.assert_called_once() - - -def test_init_missing_project_endpoint() -> None: - """Test AzureAIClient initialization when project_endpoint is missing and no project_client provided.""" - with patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError, match="Azure AI project endpoint is required"): - AzureAIClient(credential=MagicMock()) - - -def test_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIClient.__init__ when credential is missing and no project_client provided.""" - with pytest.raises(ValueError, match="Azure credential is required when project_client is not provided"): - AzureAIClient( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - - -async def test_get_agent_reference_or_create_existing_version( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when agent_version is already provided.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", agent_version="1.0") - - agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore - - assert agent_ref == {"name": "existing-agent", "version": "1.0", "type": "agent_reference"} - - -async def test_get_agent_reference_or_create_missing_agent_name( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create raises when agent_name is missing.""" - client = create_test_azure_ai_client(mock_project_client, agent_name=None) - - with pytest.raises(ValueError, match="Agent name is required"): - await client._get_agent_reference_or_create({}, None) # type: ignore - - -async def test_get_agent_reference_or_create_new_agent( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test _get_agent_reference_or_create when creating a new agent.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_client( - mock_project_client, agent_name="new-agent", azure_ai_settings=azure_ai_settings - ) - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "new-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": azure_ai_settings.get("model_deployment_name")} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - assert agent_ref == {"name": "new-agent", "version": "1.0", "type": "agent_reference"} - assert client.agent_name == "new-agent" - assert client.agent_version == "1.0" - - -async def test_get_agent_reference_missing_model( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when model is missing for agent creation.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - with pytest.raises(ValueError, match="Model deployment name is required for agent creation"): - await client._get_agent_reference_or_create({}, None) # type: ignore - - -async def test_prepare_messages_for_azure_ai_with_system_messages( - mock_project_client: MagicMock, -) -> None: - """Test _prepare_messages_for_azure_ai converts system/developer messages to instructions.""" - client = create_test_azure_ai_client(mock_project_client) - - messages = [ - Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]), - Message(role="user", contents=[Content.from_text(text="Hello")]), - Message(role="assistant", contents=[Content.from_text(text="System response")]), - ] - - result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore - - assert len(result_messages) == 2 - assert result_messages[0].role == "user" - assert result_messages[1].role == "assistant" - assert instructions == "You are a helpful assistant." - - -async def test_prepare_messages_for_azure_ai_no_system_messages( - mock_project_client: MagicMock, -) -> None: - """Test _prepare_messages_for_azure_ai with no system/developer messages.""" - client = create_test_azure_ai_client(mock_project_client) - - messages = [ - Message(role="user", contents=[Content.from_text(text="Hello")]), - Message(role="assistant", contents=[Content.from_text(text="Hi there!")]), - ] - - result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore - - assert len(result_messages) == 2 - assert instructions is None - - -def test_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None: - """Test _transform_input_for_azure_ai adds required fields for Azure AI schema. - - WORKAROUND TEST: Azure AI Projects API requires 'type' at item level and - 'annotations' in output_text content items, which OpenAI's Responses API does not require. - See: https://github.com/Azure/azure-sdk-for-python/issues/44493 - See: https://github.com/microsoft/agent-framework/issues/2926 - """ - client = create_test_azure_ai_client(mock_project_client) - - # Input in OpenAI Responses API format (what agent-framework generates) - openai_format_input = [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "Hello"}, - ], - }, - { - "role": "assistant", - "content": [ - {"type": "output_text", "text": "Hi there!"}, - ], - }, - ] - - result = client._transform_input_for_azure_ai(openai_format_input) # type: ignore - - # Verify 'type': 'message' added at item level - assert result[0]["type"] == "message" - assert result[1]["type"] == "message" - - # Verify 'annotations' added ONLY to output_text (assistant) content, NOT input_text (user) - assert result[0]["content"][0]["type"] == "input_text" # user content type preserved - assert "annotations" not in result[0]["content"][0] # user message - no annotations - assert result[1]["content"][0]["type"] == "output_text" # assistant content type preserved - assert result[1]["content"][0]["annotations"] == [] # assistant message - has annotations - - # Verify original fields preserved - assert result[0]["role"] == "user" - assert result[0]["content"][0]["text"] == "Hello" - assert result[1]["role"] == "assistant" - assert result[1]["content"][0]["text"] == "Hi there!" - - -def test_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None: - """Test _transform_input_for_azure_ai preserves existing type and annotations.""" - client = create_test_azure_ai_client(mock_project_client) - - # Input that already has the fields (shouldn't duplicate) - input_with_fields = [ - { - "type": "message", - "role": "assistant", - "content": [ - {"type": "output_text", "text": "Hello", "annotations": [{"some": "annotation"}]}, - ], - }, - ] - - result = client._transform_input_for_azure_ai(input_with_fields) # type: ignore - - # Should preserve existing values, not overwrite - assert result[0]["type"] == "message" - assert result[0]["content"][0]["annotations"] == [{"some": "annotation"}] - - -def test_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None: - """Test _transform_input_for_azure_ai handles non-dict content items.""" - client = create_test_azure_ai_client(mock_project_client) - - # Input with string content (edge case) - input_with_string_content = [ - { - "role": "user", - "content": ["plain string content"], - }, - ] - - result = client._transform_input_for_azure_ai(input_with_string_content) # type: ignore - - # Should add 'type': 'message' at item level even with non-dict content - assert result[0]["type"] == "message" - # Non-dict content items should be preserved without modification - assert result[0]["content"] == ["plain string content"] - - -async def test_prepare_options_basic(mock_project_client: MagicMock) -> None: - """Test prepare_options basic functionality.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - - -@pytest.mark.parametrize( - "endpoint,expects_agent", - [ - ("https://example.com/api/projects/my-project/applications/my-application/protocols", False), - ("https://example.com/api/projects/my-project", True), - ], -) -async def test_prepare_options_with_application_endpoint( - mock_azure_credential: MagicMock, endpoint: str, expects_agent: bool -) -> None: - client = AzureAIClient( - project_endpoint=endpoint, - model_deployment_name="test-model", - credential=mock_azure_credential, - agent_name="test-agent", - agent_version="1", - ) - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - if expects_agent: - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - else: - assert "extra_body" not in run_options - - -@pytest.mark.parametrize( - "endpoint,expects_agent", - [ - ("https://example.com/api/projects/my-project/applications/my-application/protocols", False), - ("https://example.com/api/projects/my-project", True), - ], -) -async def test_prepare_options_with_application_project_client( - mock_project_client: MagicMock, endpoint: str, expects_agent: bool -) -> None: - mock_project_client._config = MagicMock() - mock_project_client._config.endpoint = endpoint - - client = AzureAIClient( - project_client=mock_project_client, - model_deployment_name="test-model", - agent_name="test-agent", - agent_version="1", - ) - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - if expects_agent: - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - else: - assert "extra_body" not in run_options - - -def test_update_agent_name_and_description(mock_project_client: MagicMock) -> None: - """Test _update_agent_name_and_description method.""" - client = create_test_azure_ai_client(mock_project_client) - - # Test updating agent name when current is None - with patch.object(client, "_update_agent_name_and_description") as mock_update: - mock_update.return_value = None - client._update_agent_name_and_description("new-agent") # type: ignore - mock_update.assert_called_once_with("new-agent") - - # Test behavior when agent name is updated - assert client.agent_name is None # Should remain None since we didn't actually update - client.agent_name = "test-agent" # Manually set for the test - - # Test with None input - with patch.object(client, "_update_agent_name_and_description") as mock_update: - mock_update.return_value = None - client._update_agent_name_and_description(None) # type: ignore - mock_update.assert_called_once_with(None) - - -def test_as_agent_uses_client_agent_name_as_default(mock_project_client: MagicMock) -> None: - """Test that as_agent() defaults Agent.name to client.agent_name when name is not provided.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="my_agent") - client.agent_description = "my description" - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name == "my_agent" - assert agent.description == "my description" - - -def test_as_agent_explicit_name_overrides_client_agent_name(mock_project_client: MagicMock) -> None: - """Test that an explicit name passed to as_agent() takes precedence over client.agent_name.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.") - - assert agent.name == "explicit_name" - assert agent.description == "explicit description" - - -def test_as_agent_no_name_anywhere(mock_project_client: MagicMock) -> None: - """Test that Agent.name is None when neither as_agent name nor client.agent_name is provided.""" - client = create_test_azure_ai_client(mock_project_client) - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name is None - - -def test_as_agent_empty_string_preserves_explicit_value(mock_project_client: MagicMock) -> None: - """Test that empty-string name/description are preserved and do not fall back to client defaults.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="", description="", instructions="You are helpful.") - - assert agent.name == "" - assert agent.description == "" - - -async def test_async_context_manager(mock_project_client: MagicMock) -> None: - """Test async context manager functionality.""" - client = create_test_azure_ai_client(mock_project_client, should_close_client=True) - - mock_project_client.close = AsyncMock() - - async with client as ctx_client: - assert ctx_client is client - - # Should call close after exiting context - mock_project_client.close.assert_called_once() - - -async def test_close_method(mock_project_client: MagicMock) -> None: - """Test close method.""" - client = create_test_azure_ai_client(mock_project_client, should_close_client=True) - - mock_project_client.close = AsyncMock() - - await client.close() - - mock_project_client.close.assert_called_once() - - -async def test_close_client_when_should_close_false(mock_project_client: MagicMock) -> None: - """Test _close_client_if_needed when should_close_client is False.""" - client = create_test_azure_ai_client(mock_project_client, should_close_client=False) - - mock_project_client.close = AsyncMock() - - await client._close_client_if_needed() # type: ignore - - # Should not call close when should_close_client is False - mock_project_client.close.assert_not_called() - - -async def test_configure_azure_monitor_success(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor successfully configures Azure Monitor.""" - client = create_test_azure_ai_client(mock_project_client) - - # Mock the telemetry connection string retrieval - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" - ) - - mock_configure = MagicMock() - mock_views = MagicMock(return_value=[]) - mock_resource = MagicMock() - mock_enable = MagicMock() - - with ( - patch.dict( - "sys.modules", - {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, - ), - patch("agent_framework.observability.create_metric_views", mock_views), - patch("agent_framework.observability.create_resource", return_value=mock_resource), - patch("agent_framework.observability.enable_instrumentation", mock_enable), - ): - await client.configure_azure_monitor(enable_sensitive_data=True) - - # Verify connection string was retrieved - mock_project_client.telemetry.get_application_insights_connection_string.assert_called_once() - - # Verify Azure Monitor was configured - mock_configure.assert_called_once() - call_kwargs = mock_configure.call_args[1] - assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" - - # Verify instrumentation was enabled with sensitive data flag - mock_enable.assert_called_once_with(enable_sensitive_data=True) - - -async def test_configure_azure_monitor_resource_not_found(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor handles ResourceNotFoundError gracefully.""" - client = create_test_azure_ai_client(mock_project_client) - - # Mock the telemetry to raise ResourceNotFoundError - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - side_effect=ResourceNotFoundError("No Application Insights found") - ) - - # Should not raise, just log warning and return - await client.configure_azure_monitor() - - # Verify connection string retrieval was attempted - mock_project_client.telemetry.get_application_insights_connection_string.assert_called_once() - - -async def test_configure_azure_monitor_import_error(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor raises ImportError when azure-monitor-opentelemetry is not installed.""" - client = create_test_azure_ai_client(mock_project_client) - - # Mock the telemetry connection string retrieval - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - return_value="InstrumentationKey=test-key" - ) - - # Mock the import to fail - with ( - patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), - patch("builtins.__import__", side_effect=ImportError("No module named 'azure.monitor.opentelemetry'")), - pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), - ): - await client.configure_azure_monitor() - - -async def test_configure_azure_monitor_with_custom_resource(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor uses custom resource when provided.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - return_value="InstrumentationKey=test-key" - ) - - custom_resource = MagicMock() - mock_configure = MagicMock() - - with ( - patch.dict( - "sys.modules", - {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, - ), - patch("agent_framework.observability.create_metric_views") as mock_views, - patch("agent_framework.observability.create_resource") as mock_create_resource, - patch("agent_framework.observability.enable_instrumentation"), - ): - mock_views.return_value = [] - - await client.configure_azure_monitor(resource=custom_resource) - - # Verify custom resource was used, not create_resource - mock_create_resource.assert_not_called() - call_kwargs = mock_configure.call_args[1] - assert call_kwargs["resource"] is custom_resource - - -async def test_agent_creation_with_instructions( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with combined instructions.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model"} - chat_options = {"instructions": "Option instructions. "} - messages_instructions = "Message instructions. " - - await client._get_agent_reference_or_create(run_options, messages_instructions, chat_options) # type: ignore - - # Verify agent was created with combined instructions - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].instructions == "Message instructions. Option instructions. " - - -async def test_agent_creation_with_instructions_from_chat_options( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with instructions passed only via chat_options.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model"} - chat_options = {"instructions": "Chat options instructions."} - - await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore - - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].instructions == "Chat options instructions." - - -async def test_agent_creation_with_additional_args( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with additional arguments.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model", "temperature": 0.9, "top_p": 0.8} - messages_instructions = "Message instructions. " - - await client._get_agent_reference_or_create(run_options, messages_instructions) # type: ignore - - # Verify agent was created with provided arguments - call_args = mock_project_client.agents.create_version.call_args - definition = call_args[1]["definition"] - assert definition.temperature == 0.9 - assert definition.top_p == 0.8 - - -async def test_agent_creation_with_tools( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with tools.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - test_tools = [{"type": "function", "function": {"name": "test_tool"}}] - run_options = {"model": "test-model", "tools": test_tools} - - await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify agent was created with tools - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].tools == test_tools - - -async def test_runtime_tools_override_logs_warning( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when runtime tools differ from creation-time tools.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, - ): - await client._prepare_options(messages, {}) - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_two"}]}, - ), - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - await client._prepare_options(messages, {}) - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - - -async def test_prepare_options_logs_warning_for_tools_with_existing_agent_version( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when tools are supplied against an existing agent version.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, - ), - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - run_options = await client._prepare_options(messages, {}) - - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - assert "tools" not in run_options - - -async def test_prepare_options_logs_warning_for_tools_on_application_endpoint( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when runtime tools are removed for application endpoints.""" - client = create_test_azure_ai_client(mock_project_client) - client._is_application_endpoint = True # type: ignore - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, - ), - patch.object(client, "_get_agent_reference_or_create", new_callable=AsyncMock) as mock_get_agent_reference, - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - run_options = await client._prepare_options(messages, {}) - - mock_get_agent_reference.assert_not_called() - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - assert "tools" not in run_options - assert "extra_body" not in run_options - - -async def test_use_latest_version_existing_agent( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when use_latest_version=True and agent exists.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", use_latest_version=True) - - # Mock existing agent response - mock_existing_agent = MagicMock() - mock_existing_agent.name = "existing-agent" - mock_existing_agent.versions.latest.version = "2.5" - mock_project_client.agents.get = AsyncMock(return_value=mock_existing_agent) - - run_options = {"model": "test-model"} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify existing agent was retrieved and used - mock_project_client.agents.get.assert_called_once_with("existing-agent") - mock_project_client.agents.create_version.assert_not_called() - - assert agent_ref == {"name": "existing-agent", "version": "2.5", "type": "agent_reference"} - assert client.agent_name == "existing-agent" - assert client.agent_version == "2.5" - - -async def test_use_latest_version_agent_not_found( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when use_latest_version=True but agent doesn't exist.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="non-existing-agent", use_latest_version=True) - - # Mock ResourceNotFoundError when trying to retrieve agent - mock_project_client.agents.get = AsyncMock(side_effect=ResourceNotFoundError("Agent not found")) - - # Mock agent creation response for fallback - mock_created_agent = MagicMock() - mock_created_agent.name = "non-existing-agent" - mock_created_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) - - run_options = {"model": "test-model"} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify retrieval was attempted and creation was used as fallback - mock_project_client.agents.get.assert_called_once_with("non-existing-agent") - mock_project_client.agents.create_version.assert_called_once() - - assert agent_ref == {"name": "non-existing-agent", "version": "1.0", "type": "agent_reference"} - assert client.agent_name == "non-existing-agent" - assert client.agent_version == "1.0" - - -async def test_use_latest_version_false( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when use_latest_version=False (default behavior).""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", use_latest_version=False) - - # Mock agent creation response - mock_created_agent = MagicMock() - mock_created_agent.name = "test-agent" - mock_created_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) - - run_options = {"model": "test-model"} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify retrieval was not attempted and creation was used directly - mock_project_client.agents.get.assert_not_called() - mock_project_client.agents.create_version.assert_called_once() - - assert agent_ref == {"name": "test-agent", "version": "1.0", "type": "agent_reference"} - - -async def test_use_latest_version_with_existing_agent_version( - mock_project_client: MagicMock, -) -> None: - """Test that use_latest_version is ignored when agent_version is already provided.""" - client = create_test_azure_ai_client( - mock_project_client, agent_name="test-agent", agent_version="3.0", use_latest_version=True - ) - - agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore - - # Verify neither retrieval nor creation was attempted since version is already set - mock_project_client.agents.get.assert_not_called() - mock_project_client.agents.create_version.assert_not_called() - - assert agent_ref == {"name": "test-agent", "version": "3.0", "type": "agent_reference"} - - -class ResponseFormatModel(BaseModel): - """Test Pydantic model for response format testing.""" - - name: str - value: int - description: str - model_config = ConfigDict(extra="forbid") - - -class AlternateResponseFormatModel(BaseModel): - """Alternate model for structured output warning checks.""" - - summary: str - confidence: float - - -async def test_agent_creation_with_response_format( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with response_format configuration.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model"} - chat_options = {"response_format": ResponseFormatModel} - - await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore - - # Verify agent was created with response format configuration - call_args = mock_project_client.agents.create_version.call_args - created_definition = call_args[1]["definition"] - - # Check that text format configuration was set - assert hasattr(created_definition, "text") - assert created_definition.text is not None - - # Check that the format is a TextResponseFormatJsonSchema - assert hasattr(created_definition.text, "format") - format_config = created_definition.text.format - assert isinstance(format_config, TextResponseFormatJsonSchema) - - # Check the schema name matches the model class name - assert format_config.name == "ResponseFormatModel" - - # Check that schema was generated correctly - assert format_config.schema is not None - schema = format_config.schema - assert "properties" in schema - assert "name" in schema["properties"] - assert "value" in schema["properties"] - assert "description" in schema["properties"] - assert "additionalProperties" in schema - - -async def test_agent_creation_with_mapping_response_format( - mock_project_client: MagicMock, -) -> None: - """Test agent creation when response_format is provided as a mapping.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - runtime_schema = { - "title": "WeatherDigest", - "type": "object", - "properties": { - "location": {"type": "string"}, - "conditions": {"type": "string"}, - "temperature_c": {"type": "number"}, - "advisory": {"type": "string"}, - }, - "required": ["location", "conditions", "temperature_c", "advisory"], - "additionalProperties": False, - } - - run_options = {"model": "test-model"} - response_format_mapping = { - "type": "json_schema", - "json_schema": { - "name": runtime_schema["title"], - "strict": True, - "schema": runtime_schema, - }, - } - chat_options = {"response_format": response_format_mapping} - - await client._get_agent_reference_or_create(run_options, None, chat_options) - - call_args = mock_project_client.agents.create_version.call_args - created_definition = call_args[1]["definition"] - - assert hasattr(created_definition, "text") - assert created_definition.text is not None - format_config = created_definition.text.format - assert isinstance(format_config, TextResponseFormatJsonSchema) - assert format_config.name == runtime_schema["title"] - assert format_config.schema == runtime_schema - assert format_config.strict is True - - -async def test_runtime_structured_output_override_logs_warning( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when runtime structured_output differs from creation-time configuration.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ): - await client._prepare_options(messages, {"response_format": ResponseFormatModel}) - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - await client._prepare_options(messages, {"response_format": AlternateResponseFormatModel}) - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - - -async def test_prepare_options_excludes_response_format( - mock_project_client: MagicMock, -) -> None: - """Test that prepare_options excludes response_format, text, and text_format from final run options.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - chat_options: ChatOptions = {} - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={ - "model": "test-model", - "response_format": ResponseFormatModel, - "text": {"format": {"type": "json_schema", "name": "test"}}, - "text_format": ResponseFormatModel, - }, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, chat_options) - - # response_format, text, and text_format should be excluded from final run options - # because they are configured at agent level, not request level - assert "response_format" not in run_options - assert "text" not in run_options - assert "text_format" not in run_options - # But extra_body should contain agent reference - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - - -async def test_prepare_options_keeps_values_for_unsupported_option_keys( - mock_project_client: MagicMock, -) -> None: - """Test that run_options removal only applies to known AzureAI agent-level option mappings.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={ - "model": "test-model", - "tools": [{"type": "function", "name": "weather"}], - "text": {"format": {"type": "json_schema", "name": "schema"}}, - "text_format": ResponseFormatModel, - "custom_option": "keep-me", - }, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - assert "model" not in run_options - assert "tools" not in run_options - assert "text" not in run_options - assert "text_format" not in run_options - assert run_options["custom_option"] == "keep-me" - - -def test_get_conversation_id_with_store_true_and_conversation_id() -> None: - """Test _get_conversation_id returns conversation ID when store is True and conversation exists.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response with conversation - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_conversation = MagicMock() - mock_conversation.id = "conv_67890" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "conv_67890" - - -def test_get_conversation_id_with_store_true_and_no_conversation() -> None: - """Test _get_conversation_id returns response ID when store is True and no conversation exists.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response without conversation - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_response.conversation = None - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "resp_12345" - - -def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None: - """Test _get_conversation_id returns response ID when store is True and conversation ID is empty.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response with conversation but empty ID - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_conversation = MagicMock() - mock_conversation.id = "" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "resp_12345" - - -def test_get_conversation_id_with_store_false() -> None: - """Test _get_conversation_id returns None when store is False.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response with conversation - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_conversation = MagicMock() - mock_conversation.id = "conv_67890" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=False) - - assert result is None - - -def test_get_conversation_id_with_parsed_response_and_store_true() -> None: - """Test _get_conversation_id works with ParsedResponse when store is True.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock ParsedResponse with conversation - mock_response = MagicMock(spec=ParsedResponse[BaseModel]) - mock_response.id = "resp_parsed_12345" - mock_conversation = MagicMock() - mock_conversation.id = "conv_parsed_67890" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "conv_parsed_67890" - - -def test_get_conversation_id_with_parsed_response_no_conversation() -> None: - """Test _get_conversation_id returns response ID with ParsedResponse when no conversation exists.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock ParsedResponse without conversation - mock_response = MagicMock(spec=ParsedResponse[BaseModel]) - mock_response.id = "resp_parsed_12345" - mock_response.conversation = None - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "resp_parsed_12345" - - -# region MCP Tool Dict Tests -# These tests verify that dict-based MCP tools are processed correctly by from_azure_ai_tools - - -def test_from_azure_ai_tools_mcp() -> None: - """Test from_azure_ai_tools with MCP tool.""" - mcp_tool = MCPTool(server_label="test_server", server_url="http://localhost:8080") - parsed_tools = from_azure_ai_tools([mcp_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "mcp" - assert parsed_tools[0]["server_label"] == "test_server" - assert parsed_tools[0]["server_url"] == "http://localhost:8080" - - -def test_from_azure_ai_tools_code_interpreter() -> None: - """Test from_azure_ai_tools with Code Interpreter tool.""" - ci_tool = CodeInterpreterTool(container=AutoCodeInterpreterToolParam(file_ids=["file-1"])) - parsed_tools = from_azure_ai_tools([ci_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "code_interpreter" - - -def test_from_azure_ai_tools_file_search() -> None: - """Test from_azure_ai_tools with File Search tool.""" - fs_tool = FileSearchTool(vector_store_ids=["vs-1"], max_num_results=5) - parsed_tools = from_azure_ai_tools([fs_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "file_search" - assert parsed_tools[0]["vector_store_ids"] == ["vs-1"] - assert parsed_tools[0]["max_num_results"] == 5 - - -def test_from_azure_ai_tools_web_search() -> None: - """Test from_azure_ai_tools with Web Search tool.""" - ws_tool = WebSearchPreviewTool( - user_location=ApproximateLocation(city="Seattle", country="US", region="WA", timezone="PST") - ) - parsed_tools = from_azure_ai_tools([ws_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "web_search_preview" - assert parsed_tools[0]["user_location"]["city"] == "Seattle" - - -# endregion - -# region Integration Tests - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -class OutputStruct(BaseModel): - """A structured output for testing purposes.""" - - location: str - weather: str - - -@fixture -async def client() -> AsyncGenerator[AzureAIClient, None]: - """Create a client to test with.""" - agent_name = f"test-agent-{uuid4()}" - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential) as project_client, - ): - client = AzureAIClient( - project_client=project_client, - agent_name=agent_name, - ) - try: - assert client.function_invocation_configuration - # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response - client.function_invocation_configuration["max_iterations"] = 2 - yield client - finally: - await project_client.agents.delete(agent_name=agent_name) - - -# region Factory Method Tests - - -def test_get_code_interpreter_tool_basic() -> None: - """Test get_code_interpreter_tool returns CodeInterpreterTool.""" - tool = AzureAIClient.get_code_interpreter_tool() - assert isinstance(tool, CodeInterpreterTool) - - -def test_get_code_interpreter_tool_with_file_ids() -> None: - """Test get_code_interpreter_tool with file_ids.""" - tool = AzureAIClient.get_code_interpreter_tool(file_ids=["file-123", "file-456"]) - assert isinstance(tool, CodeInterpreterTool) - assert tool["container"]["file_ids"] == ["file-123", "file-456"] - - -def test_get_code_interpreter_tool_with_content() -> None: - """Test get_code_interpreter_tool accepts Content.from_hosted_file in file_ids.""" - from agent_framework import Content - - content = Content.from_hosted_file("file-content-123") - tool = AzureAIClient.get_code_interpreter_tool(file_ids=[content]) - assert isinstance(tool, CodeInterpreterTool) - assert tool["container"]["file_ids"] == ["file-content-123"] - - -def test_get_code_interpreter_tool_with_mixed_file_ids() -> None: - """Test get_code_interpreter_tool accepts a mix of strings and Content objects.""" - from agent_framework import Content - - content = Content.from_hosted_file("file-from-content") - tool = AzureAIClient.get_code_interpreter_tool(file_ids=["file-plain", content]) - assert isinstance(tool, CodeInterpreterTool) - assert sorted(tool["container"]["file_ids"]) == ["file-from-content", "file-plain"] - - -def test_get_code_interpreter_tool_content_unsupported_type() -> None: - """Test get_code_interpreter_tool raises ValueError for unsupported Content types.""" - from agent_framework import Content - - content = Content.from_hosted_vector_store("vs-123") - with pytest.raises(ValueError, match="Unsupported Content type"): - AzureAIClient.get_code_interpreter_tool(file_ids=[content]) - - -def test_get_file_search_tool_basic() -> None: - """Test get_file_search_tool returns FileSearchTool.""" - tool = AzureAIClient.get_file_search_tool(vector_store_ids=["vs-123"]) - assert isinstance(tool, FileSearchTool) - assert tool["vector_store_ids"] == ["vs-123"] - - -def test_get_file_search_tool_with_options() -> None: - """Test get_file_search_tool with max_num_results.""" - tool = AzureAIClient.get_file_search_tool( - vector_store_ids=["vs-123"], - max_num_results=10, - ) - assert isinstance(tool, FileSearchTool) - assert tool["max_num_results"] == 10 - - -def test_get_file_search_tool_requires_vector_store_ids() -> None: - """Test get_file_search_tool raises ValueError when vector_store_ids is empty.""" - with pytest.raises(ValueError, match="vector_store_ids"): - AzureAIClient.get_file_search_tool(vector_store_ids=[]) - - -def test_get_web_search_tool_basic() -> None: - """Test get_web_search_tool returns WebSearchPreviewTool.""" - tool = AzureAIClient.get_web_search_tool() - assert isinstance(tool, WebSearchPreviewTool) - - -def test_get_web_search_tool_with_location() -> None: - """Test get_web_search_tool with user_location.""" - tool = AzureAIClient.get_web_search_tool( - user_location={"city": "Seattle", "country": "US"}, - ) - assert isinstance(tool, WebSearchPreviewTool) - assert tool.user_location is not None - assert tool.user_location.city == "Seattle" - assert tool.user_location.country == "US" - - -def test_get_web_search_tool_with_search_context_size() -> None: - """Test get_web_search_tool with search_context_size.""" - tool = AzureAIClient.get_web_search_tool(search_context_size="high") - assert isinstance(tool, WebSearchPreviewTool) - assert tool.search_context_size == "high" - - -def test_get_mcp_tool_basic() -> None: - """Test get_mcp_tool returns MCPTool.""" - tool = AzureAIClient.get_mcp_tool(name="test_mcp", url="https://example.com") - assert isinstance(tool, MCPTool) - assert tool["server_label"] == "test_mcp" - assert tool["server_url"] == "https://example.com" - - -def test_get_mcp_tool_with_description() -> None: - """Test get_mcp_tool with description.""" - tool = AzureAIClient.get_mcp_tool( - name="test_mcp", - url="https://example.com", - description="Test MCP server", - ) - assert tool["server_description"] == "Test MCP server" - - -def test_get_mcp_tool_with_project_connection_id() -> None: - """Test get_mcp_tool with project_connection_id.""" - tool = AzureAIClient.get_mcp_tool( - name="test_mcp", - project_connection_id="conn-123", - ) - assert tool["project_connection_id"] == "conn-123" - - -def test_get_image_generation_tool_basic() -> None: - """Test get_image_generation_tool returns ImageGenTool.""" - tool = AzureAIClient.get_image_generation_tool() - assert isinstance(tool, ImageGenTool) - - -def test_get_image_generation_tool_with_options() -> None: - """Test get_image_generation_tool with various options.""" - tool = AzureAIClient.get_image_generation_tool( - size="1024x1024", - quality="high", - output_format="png", - ) - assert isinstance(tool, ImageGenTool) - assert tool["size"] == "1024x1024" - assert tool["quality"] == "high" - assert tool["output_format"] == "png" - - -# endregion - - -# region Azure AI Search Citation Enhancement Tests - - -def test_extract_azure_search_urls_with_dict_items(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with dict-style output (after JSON parsing).""" - client = create_test_azure_ai_client(mock_project_client) - mock_output = { - "documents": [{"id": "1", "url": "https://search.example.com/"}], - "get_urls": [ - "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01", - "https://search.example.com/indexes/idx/docs/2?api-version=2024-07-01", - ], - } - mock_search_item = MagicMock() - mock_search_item.type = "azure_ai_search_call_output" - mock_search_item.output = mock_output - - mock_call_item = MagicMock() - mock_call_item.type = "azure_ai_search_call" - - mock_msg_item = MagicMock() - mock_msg_item.type = "message" - - urls = client._extract_azure_search_urls([mock_call_item, mock_search_item, mock_msg_item]) - assert len(urls) == 2 - assert urls[0] == "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01" - assert urls[1] == "https://search.example.com/indexes/idx/docs/2?api-version=2024-07-01" - - -def test_extract_azure_search_urls_with_object_items(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with object-style output items.""" - client = create_test_azure_ai_client(mock_project_client) - mock_output = MagicMock() - mock_output.get_urls = ["https://example.com/doc/1", "https://example.com/doc/2"] - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = mock_output - - urls = client._extract_azure_search_urls([mock_item]) - assert urls == ["https://example.com/doc/1", "https://example.com/doc/2"] - - -def test_extract_azure_search_urls_no_search_items(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with no search output items.""" - client = create_test_azure_ai_client(mock_project_client) - mock_item = MagicMock() - mock_item.type = "message" - urls = client._extract_azure_search_urls([mock_item]) - assert urls == [] - - -def test_extract_azure_search_urls_with_json_string_output(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with JSON string output (non-streaming pydantic extra field).""" - client = create_test_azure_ai_client(mock_project_client) - json_output = json.dumps({ - "documents": [{"id": "1"}], - "get_urls": [ - "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01", - ], - }) - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = json_output - - urls = client._extract_azure_search_urls([mock_item]) - assert len(urls) == 1 - assert urls[0] == "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01" - - -def test_get_search_doc_url_valid(mock_project_client: MagicMock) -> None: - """Test _get_search_doc_url with valid doc_N title.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://example.com/doc/0", "https://example.com/doc/1", "https://example.com/doc/2"] - - assert client._get_search_doc_url("doc_0", get_urls) == "https://example.com/doc/0" - assert client._get_search_doc_url("doc_1", get_urls) == "https://example.com/doc/1" - assert client._get_search_doc_url("doc_2", get_urls) == "https://example.com/doc/2" - - -def test_get_search_doc_url_out_of_range(mock_project_client: MagicMock) -> None: - """Test _get_search_doc_url with out-of-range index.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://example.com/doc/0"] - assert client._get_search_doc_url("doc_5", get_urls) is None - - -def test_get_search_doc_url_no_match(mock_project_client: MagicMock) -> None: - """Test _get_search_doc_url with non-matching title.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://example.com/doc/0"] - assert client._get_search_doc_url("some_title", get_urls) is None - assert client._get_search_doc_url(None, get_urls) is None - assert client._get_search_doc_url("doc_0", []) is None - - -def test_enrich_annotations_with_search_urls(mock_project_client: MagicMock) -> None: - """Test _enrich_annotations_with_search_urls enriches citation annotations.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - "https://search.example.com/indexes/idx/docs/41?api-version=2024-07-01", - ] - - content = Content.from_text(text="test response") - content.annotations = [ - { - "type": "citation", - "title": "doc_0", - "url": "https://search.example.com/", - }, - { - "type": "citation", - "title": "doc_1", - "url": "https://search.example.com/", - }, - ] - - client._enrich_annotations_with_search_urls([content], get_urls) - - assert content.annotations[0]["additional_properties"]["get_url"] == get_urls[0] - assert content.annotations[1]["additional_properties"]["get_url"] == get_urls[1] - - -def test_enrich_annotations_no_match(mock_project_client: MagicMock) -> None: - """Test _enrich_annotations_with_search_urls with non-matching titles.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01"] - - content = Content.from_text(text="test response") - content.annotations = [ - { - "type": "citation", - "title": "some_title", - "url": "https://search.example.com/", - }, - ] - - client._enrich_annotations_with_search_urls([content], get_urls) - assert "additional_properties" not in content.annotations[0] or "get_url" not in content.annotations[0].get( - "additional_properties", {} - ) - - -def test_enrich_annotations_empty_get_urls(mock_project_client: MagicMock) -> None: - """Test _enrich_annotations_with_search_urls with empty get_urls.""" - client = create_test_azure_ai_client(mock_project_client) - content = Content.from_text(text="test") - content.annotations = [{"type": "citation", "title": "doc_0", "url": "https://example.com/"}] - - # Should not raise or modify - client._enrich_annotations_with_search_urls([content], []) - assert "additional_properties" not in content.annotations[0] - - -async def test_inner_get_response_enriches_non_streaming(mock_project_client: MagicMock) -> None: - """Test _inner_get_response enriches url_citation annotations for non-streaming responses.""" - client = create_test_azure_ai_client(mock_project_client) - - # Build a ChatResponse with citation annotations and a raw_representation carrying search output - content = Content.from_text(text="Here is the result【5:0†source】.") - content.annotations = [ - Annotation(type="citation", title="doc_0", url="https://search.example.com/"), - ] - msg = Message(role="assistant", contents=[content]) - mock_raw = MagicMock() - mock_search_output = MagicMock() - mock_search_output.type = "azure_ai_search_call_output" - mock_search_output_data = MagicMock() - mock_search_output_data.get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - ] - mock_search_output.output = mock_search_output_data - mock_raw.output = [mock_search_output] - - base_response = ChatResponse(messages=[msg], raw_representation=mock_raw) - - async def _fake_awaitable() -> ChatResponse: - return base_response - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()): - result_awaitable = client._inner_get_response(messages=[], options={}, stream=False) - result = await result_awaitable # type: ignore[misc] - - ann = result.messages[0].contents[0].annotations[0] - assert ann["additional_properties"]["get_url"] == ( - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01" - ) - - -async def test_inner_get_response_no_search_output_non_streaming(mock_project_client: MagicMock) -> None: - """Test _inner_get_response passes through when no search output exists.""" - client = create_test_azure_ai_client(mock_project_client) - - content = Content.from_text(text="Hello world") - msg = Message(role="assistant", contents=[content]) - mock_raw = MagicMock() - mock_raw.output = [] - base_response = ChatResponse(messages=[msg], raw_representation=mock_raw) - - async def _fake_awaitable() -> ChatResponse: - return base_response - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()): - result_awaitable = client._inner_get_response(messages=[], options={}, stream=False) - result = await result_awaitable # type: ignore[misc] - - assert result.messages[0].contents[0].text == "Hello world" - - -def _create_mock_stream() -> MagicMock: - """Create a mock ResponseStream with working with_transform_hook.""" - mock_stream = MagicMock(spec=ResponseStream) - mock_stream._transform_hooks = [] - mock_stream.with_transform_hook.side_effect = lambda hook: mock_stream._transform_hooks.append(hook) or mock_stream - return mock_stream - - -def test_inner_get_response_streaming_registers_hook(mock_project_client: MagicMock) -> None: - """Test _inner_get_response appends a transform hook to the stream for streaming responses.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_stream = _create_mock_stream() - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): - result = client._inner_get_response(messages=[], options={}, stream=True) - - assert result is mock_stream - assert len(mock_stream._transform_hooks) == 1 - - -def test_streaming_hook_captures_search_urls(mock_project_client: MagicMock) -> None: - """Test the streaming transform hook captures get_urls from search output events.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_stream = _create_mock_stream() - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): - client._inner_get_response(messages=[], options={}, stream=True) - - hook = mock_stream._transform_hooks[0] - - # Simulate azure_ai_search_call_output event - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = MagicMock() - mock_item.output.get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - ] - - raw_event = MagicMock() - raw_event.type = "response.output_item.added" - raw_event.item = mock_item - - update = ChatResponseUpdate(raw_representation=raw_event) - result = hook(update) - assert result is update # passes through (no annotations to enrich) - - -def test_streaming_hook_enriches_url_citation(mock_project_client: MagicMock) -> None: - """Test the streaming transform hook enriches url_citation annotations with get_urls.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_stream = _create_mock_stream() - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): - client._inner_get_response(messages=[], options={}, stream=True) - - hook = mock_stream._transform_hooks[0] - - # Step 1: Feed search output event to capture URLs - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = MagicMock() - mock_item.output.get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - "https://search.example.com/indexes/idx/docs/41?api-version=2024-07-01", - ] - raw_output_event = MagicMock() - raw_output_event.type = "response.output_item.added" - raw_output_event.item = mock_item - hook(ChatResponseUpdate(raw_representation=raw_output_event)) - - # Step 2: Feed url_citation annotation event (annotation is always a dict in streaming) - raw_ann_event = MagicMock() - raw_ann_event.type = "response.output_text.annotation.added" - raw_ann_event.annotation = { - "type": "url_citation", - "title": "doc_0", - "url": "https://search.example.com/", - "start_index": 100, - "end_index": 112, - } - raw_ann_event.annotation_index = 0 - - result = hook(ChatResponseUpdate(raw_representation=raw_ann_event)) - - # Verify the result has enriched annotation - assert result.contents is not None - found = False - for content_item in result.contents: - if hasattr(content_item, "annotations") and content_item.annotations: - for ann in content_item.annotations: - if isinstance(ann, dict) and ann.get("title") == "doc_0": - found = True - assert ann["additional_properties"]["get_url"] == ( - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01" - ) - assert found, "Expected url_citation annotation with enriched get_url" - - -def test_build_url_citation_content(mock_project_client: MagicMock) -> None: - """Test _build_url_citation_content creates Content with enriched Annotation.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01"] - - annotation_data = { - "type": "url_citation", - "title": "doc_0", - "url": "https://search.example.com/", - "start_index": 100, - "end_index": 112, - } - - raw_event = MagicMock() - raw_event.annotation_index = 0 - - content = client._build_url_citation_content(annotation_data, get_urls, raw_event) - - assert content.annotations is not None - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["title"] == "doc_0" - assert ann["url"] == "https://search.example.com/" - assert ann["additional_properties"]["get_url"] == get_urls[0] - assert ann["annotated_regions"][0]["start_index"] == 100 - assert ann["annotated_regions"][0]["end_index"] == 112 - - -def test_build_url_citation_content_with_dict(mock_project_client: MagicMock) -> None: - """Test _build_url_citation_content handles dict-style annotation data.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01"] - - annotation_data = { - "type": "url_citation", - "title": "doc_1", - "url": "https://search.example.com/", - "start_index": 200, - "end_index": 215, - } - - raw_event = MagicMock() - raw_event.annotation_index = 1 - - content = client._build_url_citation_content(annotation_data, get_urls, raw_event) - - assert content.annotations is not None - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["title"] == "doc_1" - # doc_1 is out of range for a 1-element get_urls, so no get_url - assert "get_url" not in ann.get("additional_properties", {}) - - -# region OAuth Consent - - -def test_parse_chunk_with_oauth_consent_request(mock_project_client: MagicMock) -> None: - """Test that a streaming oauth_consent_request output item is parsed into oauth_consent_request content. - - This reproduces the bug from issue #3950 where the event was logged as "Unparsed event" - and silently discarded, causing the agent run to complete with zero content. - """ - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - chat_options: dict[str, Any] = {} - function_call_ids: dict[int, tuple[str, str]] = {} - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc123" - - mock_event = MagicMock() - mock_event.type = "response.output_item.added" - mock_event.item = mock_item - mock_event.output_index = 0 - - update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) - - assert len(update.contents) == 1 - consent_content = update.contents[0] - assert consent_content.type == "oauth_consent_request" - assert consent_content.consent_link == "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc123" - assert consent_content.user_input_request is True - - -def test_parse_response_with_oauth_consent_output_item(mock_project_client: MagicMock) -> None: - """Test that a non-streaming oauth_consent_request output item is parsed correctly.""" - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = "https://login.microsoftonline.com/consent?code=abc" - - mock_response = MagicMock() - mock_response.output = [mock_item] - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.id = "resp-oauth-1" - mock_response.model = "test-model" - mock_response.created_at = 1000000000 - mock_response.usage = None - mock_response.status = "completed" - - response = client._parse_response_from_openai(mock_response, {}) - - assert len(response.messages) > 0 - consent_contents = [c for c in response.messages[0].contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 1 - assert consent_contents[0].consent_link == "https://login.microsoftonline.com/consent?code=abc" - - -def test_parse_chunk_oauth_consent_no_link(mock_project_client: MagicMock) -> None: - """Test that a streaming oauth_consent_request with no consent_link produces empty contents.""" - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = "" - - mock_event = MagicMock() - mock_event.type = "response.output_item.added" - mock_event.item = mock_item - mock_event.output_index = 0 - - update = client._parse_chunk_from_openai(mock_event, {}, {}) - - assert not any(c.type == "oauth_consent_request" for c in update.contents) - - -def test_parse_response_oauth_consent_no_link(mock_project_client: MagicMock) -> None: - """Test that a non-streaming oauth_consent_request with no consent_link appends no content.""" - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = None - - mock_response = MagicMock() - mock_response.output = [mock_item] - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.id = "resp-oauth-2" - mock_response.model = "test-model" - mock_response.created_at = 1000000000 - mock_response.usage = None - mock_response.status = "completed" - - response = client._parse_response_from_openai(mock_response, {}) - - consent_contents = [c for c in response.messages[0].contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 - - -# endregion diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py deleted file mode 100644 index bc85948fca..0000000000 --- a/python/packages/azure-ai/tests/test_provider.py +++ /dev/null @@ -1,682 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import Agent, FunctionTool -from agent_framework._mcp import MCPTool -from azure.ai.projects.models import ( - AgentVersionDetails, - PromptAgentDefinition, -) -from azure.ai.projects.models import ( - FunctionTool as AzureFunctionTool, -) - -from agent_framework_azure_ai import AzureAIProjectAgentProvider - - -@pytest.fixture -def mock_project_client() -> MagicMock: - """Fixture that provides a mock AIProjectClient.""" - mock_client = MagicMock() - - # Mock agents property - mock_client.agents = MagicMock() - mock_client.agents.create_version = AsyncMock() - - # Mock conversations property - mock_client.conversations = MagicMock() - mock_client.conversations.create = AsyncMock() - - # Mock telemetry property - mock_client.telemetry = MagicMock() - mock_client.telemetry.get_application_insights_connection_string = AsyncMock() - - # Mock get_openai_client method - mock_client.get_openai_client = AsyncMock() - - # Mock close method - mock_client.close = AsyncMock() - - return mock_client - - -@pytest.fixture -def mock_azure_credential() -> MagicMock: - """Fixture that provides a mock Azure credential.""" - return MagicMock() - - -@pytest.fixture -def azure_ai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: - """Fixture that sets up Azure AI environment variables for unit testing.""" - env_vars = { - "AZURE_AI_PROJECT_ENDPOINT": "https://test-project.cognitiveservices.azure.com/", - "AZURE_AI_MODEL_DEPLOYMENT_NAME": "test-model-deployment", - } - for key, value in env_vars.items(): - monkeypatch.setenv(key, value) - return env_vars - - -def test_provider_init_with_project_client(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider initialization with existing project_client.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - assert provider._project_client is mock_project_client # type: ignore - assert not provider._should_close_client # type: ignore - - -def test_provider_init_with_credential_and_endpoint( - azure_ai_unit_test_env: dict[str, str], - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIProjectAgentProvider initialization with credential and endpoint.""" - with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client: - mock_client = MagicMock() - mock_ai_project_client.return_value = mock_client - - provider = AzureAIProjectAgentProvider( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - credential=mock_azure_credential, - ) - - assert provider._project_client is mock_client # type: ignore - assert provider._should_close_client # type: ignore - - # Verify AIProjectClient was called with correct parameters - mock_ai_project_client.assert_called_once() - - -def test_provider_init_missing_endpoint() -> None: - """Test AzureAIProjectAgentProvider initialization when endpoint is missing.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError, match="Azure AI project endpoint is required"): - AzureAIProjectAgentProvider(credential=MagicMock()) - - -def test_provider_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIProjectAgentProvider initialization when credential is missing.""" - with pytest.raises(ValueError, match="Azure credential is required when project_client is not provided"): - AzureAIProjectAgentProvider( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - ) - - -async def test_provider_create_agent( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent method.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = 0.7 - mock_agent_version.definition.top_p = 0.9 - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - agent = await provider.create_agent( - name="test-agent", - model="gpt-4", - instructions="Test instructions", - description="Test Agent", - ) - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - mock_project_client.agents.create_version.assert_called_once() - - -async def test_provider_create_agent_with_env_model( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent uses model from env var.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - mock_agent_version.definition.instructions = None - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Call without model parameter - should use env var - agent = await provider.create_agent(name="test-agent") - - assert isinstance(agent, Agent) - # Verify the model from env var was used - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].model == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - - -async def test_provider_create_agent_missing_model(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.create_agent raises when model is missing.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - with pytest.raises(ValueError, match="Model deployment name is required"): - await provider.create_agent(name="test-agent") - - -async def test_provider_create_agent_with_rai_config( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent passes rai_config from default_options.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = None - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Create a mock RaiConfig-like object - mock_rai_config = MagicMock() - mock_rai_config.rai_policy_name = "policy-name" - - # Call create_agent with rai_config in default_options - await provider.create_agent( - name="test-agent", - model="gpt-4", - default_options={"rai_config": mock_rai_config}, - ) - - # Verify rai_config was passed to PromptAgentDefinition - call_args = mock_project_client.agents.create_version.call_args - definition = call_args[1]["definition"] - assert definition.rai_config is mock_rai_config - - -async def test_provider_create_agent_with_reasoning( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent passes reasoning from default_options.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-5.2" - mock_agent_version.definition.instructions = None - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Create a mock Reasoning-like object - mock_reasoning = MagicMock() - mock_reasoning.effort = "medium" - mock_reasoning.summary = "concise" - - # Call create_agent with reasoning in default_options - await provider.create_agent( - name="test-agent", - model="gpt-5.2", - default_options={"reasoning": mock_reasoning}, - ) - - # Verify reasoning was passed to PromptAgentDefinition - call_args = mock_project_client.agents.create_version.call_args - definition = call_args[1]["definition"] - assert definition.reasoning is mock_reasoning - - -async def test_provider_get_agent_with_name(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent with name parameter.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_agent_object = MagicMock() - mock_agent_object.versions.latest = mock_agent_version - - mock_project_client.agents = AsyncMock() - mock_project_client.agents.get.return_value = mock_agent_object - - agent = await provider.get_agent(name="test-agent") - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - mock_project_client.agents.get.assert_called_with(agent_name="test-agent") - - -async def test_provider_get_agent_with_reference(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent with reference parameter.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents = AsyncMock() - mock_project_client.agents.get_version.return_value = mock_agent_version - - agent_reference = {"name": "test-agent", "version": "1.0"} - agent = await provider.get_agent(reference=agent_reference) - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - mock_project_client.agents.get_version.assert_called_with(agent_name="test-agent", agent_version="1.0") - - -async def test_provider_get_agent_missing_parameters(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent raises when no identifier provided.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - with pytest.raises(ValueError, match="Either name or reference must be provided"): - await provider.get_agent() - - -async def test_provider_get_agent_missing_function_tools(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent raises when required tools are missing.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent with function tools - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.tools = [ - AzureFunctionTool(name="test_tool", parameters=[], strict=True, description="Test tool") - ] - - mock_agent_object = MagicMock() - mock_agent_object.versions.latest = mock_agent_version - - mock_project_client.agents = AsyncMock() - mock_project_client.agents.get.return_value = mock_agent_object - - with pytest.raises( - ValueError, match="The following prompt agent definition required tools were not provided: test_tool" - ): - await provider.get_agent(name="test-agent") - - -def test_provider_as_agent(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.as_agent method.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Create mock agent version - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = 0.7 - mock_agent_version.definition.top_p = 0.9 - mock_agent_version.definition.tools = [] - - with patch("agent_framework_azure_ai._project_provider.AzureAIClient") as mock_azure_ai_client: - agent = provider.as_agent(mock_agent_version) - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - assert agent.description == "Test Agent" - - # Verify AzureAIClient was called with correct parameters - mock_azure_ai_client.assert_called_once() - call_kwargs = mock_azure_ai_client.call_args[1] - assert call_kwargs["project_client"] is mock_project_client - assert call_kwargs["agent_name"] == "test-agent" - assert call_kwargs["agent_version"] == "1.0" - assert call_kwargs["agent_description"] == "Test Agent" - assert call_kwargs["model_deployment_name"] == "gpt-4" - - -def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: MagicMock) -> None: - """Test that _merge_tools skips function tool dicts but keeps other hosted tools.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Create a mock FunctionTool to provide as implementation - mock_ai_function = create_mock_ai_function("my_function", "My function description") - - # Definition tools include a function tool (dict) and an MCP tool - definition_tools = [ - {"type": "function", "name": "my_function", "parameters": {}}, # Should be skipped - {"type": "mcp", "server_label": "my_mcp", "server_url": "http://localhost:8080"}, # Should be converted - ] - - # Call _merge_tools with user-provided function implementation - merged = provider._merge_tools(definition_tools, [mock_ai_function]) # type: ignore - - # Should have 2 items: the converted MCP dict and the user-provided FunctionTool - assert len(merged) == 2 - - # Check that the function tool dict was NOT included (it was skipped) - function_dicts = [t for t in merged if isinstance(t, dict) and t.get("type") == "function"] - assert len(function_dicts) == 0 - - # Check that the MCP tool was converted to dict - mcp_tools = [t for t in merged if isinstance(t, dict) and t.get("type") == "mcp"] - assert len(mcp_tools) == 1 - assert mcp_tools[0]["server_label"] == "my_mcp" - - # Check that the user-provided FunctionTool was included - ai_functions = [t for t in merged if isinstance(t, FunctionTool)] - assert len(ai_functions) == 1 - assert ai_functions[0].name == "my_function" - - -async def test_provider_context_manager(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider async context manager.""" - with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client: - mock_client = MagicMock() - mock_client.close = AsyncMock() - mock_ai_project_client.return_value = mock_client - - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": "https://test.com", - "model_deployment_name": "test-model", - } - - async with AzureAIProjectAgentProvider(credential=MagicMock()) as provider: - assert provider._project_client is mock_client # type: ignore - - # Should call close after exiting context - mock_client.close.assert_called_once() - - -async def test_provider_context_manager_with_provided_client(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider context manager doesn't close provided client.""" - mock_project_client.close = AsyncMock() - - async with AzureAIProjectAgentProvider(project_client=mock_project_client) as provider: - assert provider._project_client is mock_project_client # type: ignore - - # Should NOT call close when client was provided - mock_project_client.close.assert_not_called() - - -async def test_provider_close_method(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.close method.""" - with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client: - mock_client = MagicMock() - mock_client.close = AsyncMock() - mock_ai_project_client.return_value = mock_client - - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": "https://test.com", - "model_deployment_name": "test-model", - } - - provider = AzureAIProjectAgentProvider(credential=MagicMock()) - await provider.close() - - mock_client.close.assert_called_once() - - -def test_create_text_format_config_sets_strict_for_pydantic_models() -> None: - """Test that create_text_format_config sets strict=True for Pydantic models.""" - from pydantic import BaseModel - - from agent_framework_azure_ai._shared import create_text_format_config - - class TestSchema(BaseModel): - subject: str - summary: str - - result = create_text_format_config(TestSchema) - - # Verify strict=True is set - assert result["strict"] is True - assert result["name"] == "TestSchema" - assert "schema" in result - - -class MockMCPTool(MCPTool): # pyright: ignore[reportGeneralTypeIssues] - """A mock MCPTool subclass for testing that passes isinstance checks. - - Note: This intentionally does NOT call super().__init__() because MCPTool's - constructor requires MCP server connection parameters that aren't needed for - unit testing. We only need isinstance(obj, MCPTool) to return True. - """ - - def __init__(self, functions: list[FunctionTool] | None = None) -> None: - self.name = "MockMCPTool" - self.description = "A mock MCP tool for testing" - self.is_connected = False - self._mock_functions = functions or [] - self._connect_called = False - - @property - def functions(self) -> list[FunctionTool]: - return self._mock_functions - - async def connect(self, *, reset: bool = False) -> None: - self._connect_called = True - self.is_connected = True - - -@pytest.fixture -def mock_mcp_tool() -> MockMCPTool: - """Fixture that provides a mock MCPTool.""" - mock_functions = [ - create_mock_ai_function("mcp_function_1", "First MCP function"), - create_mock_ai_function("mcp_function_2", "Second MCP function"), - ] - return MockMCPTool(functions=mock_functions) - - -def create_mock_ai_function(name: str, description: str = "A mock function") -> FunctionTool: - """Create a real FunctionTool for testing.""" - - def mock_func(arg: str) -> str: - return f"Result from {name}: {arg}" - - return FunctionTool(func=mock_func, name=name, description=description, approval_mode="never_require") - - -async def test_provider_create_agent_with_mcp_tool( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], - mock_mcp_tool: "MockMCPTool", -) -> None: - """Test that create_agent connects MCP tools and passes discovered functions to Azure AI.""" - - # Patch normalize_tools to return tools as-is in a list (avoids callable check) - def mock_normalize_tools(tools): - if tools is None: - return [] - if isinstance(tools, list): - return tools - return [tools] - - with ( - patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, - patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), - ): - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - mock_to_azure_tools.return_value = [{"type": "function", "name": "mcp_function_1"}] - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Call create_agent with MCP tool - await provider.create_agent( - name="test-agent", - model="gpt-4", - instructions="Test instructions", - tools=mock_mcp_tool, - ) - - # Verify MCP tool was connected - assert mock_mcp_tool._connect_called is True - assert mock_mcp_tool.is_connected is True - - # Verify to_azure_ai_tools was called with the discovered MCP functions - mock_to_azure_tools.assert_called_once() - tools_passed = mock_to_azure_tools.call_args[0][0] - assert len(tools_passed) == 2 - assert tools_passed[0].name == "mcp_function_1" - assert tools_passed[1].name == "mcp_function_2" - - -async def test_provider_create_agent_with_mcp_and_regular_tools( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], - mock_mcp_tool: "MockMCPTool", -) -> None: - """Test that create_agent handles both MCP tools and regular FunctionTools.""" - # Create a regular FunctionTool - regular_function = create_mock_ai_function("regular_function", "A regular function") - - # Patch normalize_tools to return tools as-is in a list (avoids callable check) - def mock_normalize_tools(tools): - if tools is None: - return [] - if isinstance(tools, list): - return tools - return [tools] - - with ( - patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, - patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), - ): - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - mock_to_azure_tools.return_value = [] - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Pass both MCP tool and regular function - await provider.create_agent( - name="test-agent", - model="gpt-4", - tools=[mock_mcp_tool, regular_function], - ) - - # Verify to_azure_ai_tools was called with: - # - The regular FunctionTool (1) - # - The 2 discovered MCP functions - mock_to_azure_tools.assert_called_once() - tools_passed = mock_to_azure_tools.call_args[0][0] - assert len(tools_passed) == 3 # 1 regular + 2 MCP functions - - # Verify the regular function is in the list - tool_names = [t.name for t in tools_passed] - assert "regular_function" in tool_names - assert "mcp_function_1" in tool_names - assert "mcp_function_2" in tool_names diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py deleted file mode 100644 index 845638ceee..0000000000 --- a/python/packages/azure-ai/tests/test_shared.py +++ /dev/null @@ -1,494 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import os -from unittest.mock import MagicMock, patch - -import pytest -from agent_framework import ( - FunctionTool, -) -from agent_framework.exceptions import IntegrationInvalidRequestException -from azure.ai.agents.models import CodeInterpreterToolDefinition -from pydantic import BaseModel - -from agent_framework_azure_ai import AzureAIAgentClient -from agent_framework_azure_ai._shared import ( - _convert_response_format, # type: ignore - _convert_sdk_tool, # type: ignore - _extract_project_connection_id, # type: ignore - create_text_format_config, - from_azure_ai_agent_tools, - from_azure_ai_tools, - to_azure_ai_agent_tools, - to_azure_ai_tools, -) -from agent_framework_azure_ai._shared import ( - _prepare_mcp_tool_dict_for_azure_ai as _prepare_mcp_tool_for_azure_ai, # type: ignore -) - - -def test_extract_project_connection_id_direct() -> None: - """Test extracting project_connection_id from direct key.""" - result = _extract_project_connection_id({"project_connection_id": "my-connection"}) - assert result == "my-connection" - - -def test_extract_project_connection_id_from_connection_name() -> None: - """Test extracting project_connection_id from connection.name structure.""" - result = _extract_project_connection_id({"connection": {"name": "my-connection"}}) - assert result == "my-connection" - - -def test_extract_project_connection_id_none() -> None: - """Test returns None when no connection info.""" - assert _extract_project_connection_id(None) is None - assert _extract_project_connection_id({}) is None - - -def test_to_azure_ai_agent_tools_empty() -> None: - """Test converting empty/None tools list.""" - assert to_azure_ai_agent_tools(None) == [] - assert to_azure_ai_agent_tools([]) == [] - - -def test_to_azure_ai_agent_tools_function_tool() -> None: - """Test converting FunctionTool to tool definition.""" - - def my_func(arg: str) -> str: - """My function.""" - return arg - - func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore - result = to_azure_ai_agent_tools([func_tool]) # type: ignore - assert len(result) == 1 - assert result[0]["type"] == "function" - assert result[0]["function"]["name"] == "my_func" - - -def test_to_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting code_interpreter dict tool.""" - tool = AzureAIAgentClient.get_code_interpreter_tool() - result = to_azure_ai_agent_tools([tool]) - assert len(result) == 1 - assert isinstance(result[0], CodeInterpreterToolDefinition) - - -def test_to_azure_ai_agent_tools_web_search_missing_connection() -> None: - """Test web search tool raises without connection info.""" - # Clear any environment variables that could provide connection info - with patch.dict( - os.environ, - {"BING_CONNECTION_ID": "", "BING_CUSTOM_CONNECTION_ID": "", "BING_CUSTOM_INSTANCE_NAME": ""}, - clear=False, - ): - # Also need to unset the keys if they exist - env_backup = {} - for key in ["BING_CONNECTION_ID", "BING_CUSTOM_CONNECTION_ID", "BING_CUSTOM_INSTANCE_NAME"]: - env_backup[key] = os.environ.pop(key, None) - try: - # get_web_search_tool now raises ValueError when no connection info is available - with pytest.raises(ValueError, match="Azure AI Agents requires a Bing connection"): - AzureAIAgentClient.get_web_search_tool() - finally: - # Restore environment - for key, value in env_backup.items(): - if value is not None: - os.environ[key] = value - - -def test_to_azure_ai_agent_tools_dict_passthrough() -> None: - """Test dict tools pass through unchanged.""" - tool_dict = {"type": "custom", "config": "value"} - result = to_azure_ai_agent_tools([tool_dict]) - assert result[0] == tool_dict - - -def test_to_azure_ai_agent_tools_unsupported_type() -> None: - """Test unsupported tool type passes through unchanged.""" - - class UnsupportedTool: - pass - - unsupported = UnsupportedTool() - result = to_azure_ai_agent_tools([unsupported]) # type: ignore - assert len(result) == 1 - assert result[0] is unsupported # Passed through unchanged - - -def test_from_azure_ai_agent_tools_empty() -> None: - """Test converting empty/None tools list.""" - assert from_azure_ai_agent_tools(None) == [] - assert from_azure_ai_agent_tools([]) == [] - - -def test_from_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting CodeInterpreterToolDefinition.""" - tool = CodeInterpreterToolDefinition() - result = from_azure_ai_agent_tools([tool]) - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - - -def test_convert_sdk_tool_code_interpreter() -> None: - """Test _convert_sdk_tool with code_interpreter type.""" - tool = MagicMock() - tool.type = "code_interpreter" - result = _convert_sdk_tool(tool) - assert result == {"type": "code_interpreter"} - - -def test_convert_sdk_tool_function_returns_none() -> None: - """Test _convert_sdk_tool with function type returns None.""" - tool = MagicMock() - tool.type = "function" - result = _convert_sdk_tool(tool) - assert result is None - - -def test_convert_sdk_tool_mcp_returns_none() -> None: - """Test _convert_sdk_tool with mcp type returns None.""" - tool = MagicMock() - tool.type = "mcp" - result = _convert_sdk_tool(tool) - assert result is None - - -def test_convert_sdk_tool_file_search() -> None: - """Test _convert_sdk_tool with file_search type.""" - tool = MagicMock() - tool.type = "file_search" - tool.file_search = MagicMock() - tool.file_search.vector_store_ids = ["vs-1", "vs-2"] - result = _convert_sdk_tool(tool) - assert result["type"] == "file_search" - assert result["vector_store_ids"] == ["vs-1", "vs-2"] - - -def test_convert_sdk_tool_bing_grounding() -> None: - """Test _convert_sdk_tool with bing_grounding type.""" - tool = MagicMock() - tool.type = "bing_grounding" - tool.bing_grounding = MagicMock() - tool.bing_grounding.connection_id = "conn-123" - result = _convert_sdk_tool(tool) - assert result["type"] == "bing_grounding" - assert result["connection_id"] == "conn-123" - - -def test_convert_sdk_tool_bing_custom_search() -> None: - """Test _convert_sdk_tool with bing_custom_search type.""" - tool = MagicMock() - tool.type = "bing_custom_search" - tool.bing_custom_search = MagicMock() - tool.bing_custom_search.connection_id = "conn-123" - tool.bing_custom_search.instance_name = "my-instance" - result = _convert_sdk_tool(tool) - assert result["type"] == "bing_custom_search" - assert result["connection_id"] == "conn-123" - assert result["instance_name"] == "my-instance" - - -def test_to_azure_ai_tools_empty() -> None: - """Test converting empty/None tools list.""" - assert to_azure_ai_tools(None) == [] - assert to_azure_ai_tools([]) == [] - - -def test_to_azure_ai_tools_code_interpreter_with_file_ids() -> None: - """Test converting code_interpreter dict tool with file inputs.""" - tool = { - "type": "code_interpreter", - "file_ids": ["file-123"], - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "code_interpreter" - - -def test_to_azure_ai_tools_function_tool() -> None: - """Test converting FunctionTool.""" - - def my_func(arg: str) -> str: - """My function.""" - return arg - - func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore - result = to_azure_ai_tools([func_tool]) # type: ignore - assert len(result) == 1 - assert result[0]["type"] == "function" - assert result[0]["name"] == "my_func" - - -def test_to_azure_ai_tools_file_search() -> None: - """Test converting file_search dict tool.""" - tool = { - "type": "file_search", - "vector_store_ids": ["vs-123"], - "max_num_results": 10, - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "file_search" - assert result[0]["vector_store_ids"] == ["vs-123"] - assert result[0]["max_num_results"] == 10 - - -def test_to_azure_ai_tools_web_search_with_location() -> None: - """Test converting web_search dict tool with user location.""" - tool = { - "type": "web_search_preview", - "user_location": { - "city": "Seattle", - "country": "US", - "region": "WA", - "timezone": "PST", - }, - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "web_search_preview" - - -def test_to_azure_ai_tools_image_generation() -> None: - """Test converting image_generation dict tool.""" - tool = { - "type": "image_generation", - "model": "gpt-image-1", - "size": "1024x1024", - "quality": "high", - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "image_generation" - assert result[0]["model"] == "gpt-image-1" - - -def test_prepare_mcp_tool_basic() -> None: - """Test basic MCP tool conversion.""" - tool = {"type": "mcp", "server_label": "my_tool", "server_url": "http://localhost:8080"} - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["server_label"] == "my_tool" - assert "http://localhost:8080" in result["server_url"] - - -def test_prepare_mcp_tool_with_description() -> None: - """Test MCP tool with description.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "server_description": "My MCP server", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["server_description"] == "My MCP server" - - -def test_prepare_mcp_tool_with_headers() -> None: - """Test MCP tool with headers (no project_connection_id).""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "headers": {"X-Api-Key": "secret"}, - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["headers"] == {"X-Api-Key": "secret"} - - -def test_prepare_mcp_tool_project_connection_takes_precedence() -> None: - """Test project_connection_id takes precedence over headers.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "headers": {"X-Api-Key": "secret"}, - "project_connection_id": "my-conn", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["project_connection_id"] == "my-conn" - assert "headers" not in result - - -def test_prepare_mcp_tool_approval_mode_always() -> None: - """Test MCP tool with always_require approval mode.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "require_approval": "always", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["require_approval"] == "always" - - -def test_prepare_mcp_tool_approval_mode_never() -> None: - """Test MCP tool with never_require approval mode.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "require_approval": "never", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["require_approval"] == "never" - - -def test_prepare_mcp_tool_approval_mode_dict() -> None: - """Test MCP tool with dict approval mode.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}}, - } - result = _prepare_mcp_tool_for_azure_ai(tool) - # The approval mode is passed through - assert "require_approval" in result - - -def test_create_text_format_config_pydantic_model() -> None: - """Test creating text format config from Pydantic model.""" - - class MySchema(BaseModel): - name: str - value: int - - result = create_text_format_config(MySchema) - assert result["type"] == "json_schema" - assert result["name"] == "MySchema" - assert result["strict"] is True - - -def test_create_text_format_config_json_schema_mapping() -> None: - """Test creating text format config from json_schema mapping.""" - config = { - "type": "json_schema", - "json_schema": { - "name": "MyResponse", - "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, - }, - } - result = create_text_format_config(config) - assert result["type"] == "json_schema" - assert result["name"] == "MyResponse" - - -def test_create_text_format_config_json_object() -> None: - """Test creating text format config for json_object type.""" - result = create_text_format_config({"type": "json_object"}) - assert result["type"] == "json_object" - - -def test_create_text_format_config_text() -> None: - """Test creating text format config for text type.""" - result = create_text_format_config({"type": "text"}) - assert result["type"] == "text" - - -def test_create_text_format_config_invalid_raises() -> None: - """Test invalid response_format raises error.""" - with pytest.raises(IntegrationInvalidRequestException): - create_text_format_config({"type": "invalid"}) - - -def test_convert_response_format_with_format_key() -> None: - """Test _convert_response_format with nested format key.""" - config = {"format": {"type": "json_object"}} - result = _convert_response_format(config) - assert result["type"] == "json_object" - - -def test_convert_response_format_json_schema_missing_schema_raises() -> None: - """Test json_schema without schema raises error.""" - with pytest.raises(IntegrationInvalidRequestException, match="requires a schema"): - _convert_response_format({"type": "json_schema", "json_schema": {}}) - - -def test_convert_response_format_raw_json_schema_with_properties() -> None: - """Test raw JSON schema with properties is wrapped in json_schema envelope.""" - result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"}) - - assert result["type"] == "json_schema" - assert result["name"] == "MyOutput" - assert result["strict"] is True - assert result["schema"]["additionalProperties"] is False - assert "title" not in result["schema"] - - -def test_convert_response_format_raw_json_schema_no_title() -> None: - """Test raw JSON schema without title defaults name to 'response'.""" - result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}}) - - assert result["name"] == "response" - - -def test_convert_response_format_raw_json_schema_with_anyof() -> None: - """Test raw JSON schema with anyOf keyword is detected.""" - result = _convert_response_format({"anyOf": [{"type": "string"}, {"type": "number"}]}) - - assert result["type"] == "json_schema" - assert result["strict"] is True - - -def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: - """Test from_azure_ai_tools converts MCP require_approval='always' to dict.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": "always", - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == "always" - - -def test_from_azure_ai_tools_mcp_approval_mode_never() -> None: - """Test from_azure_ai_tools converts MCP require_approval='never' to dict.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": "never", - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == "never" - - -def test_from_azure_ai_tools_mcp_approval_mode_dict_always() -> None: - """Test from_azure_ai_tools converts MCP dict require_approval with 'always' key.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}}, - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}} - - -def test_from_azure_ai_tools_mcp_approval_mode_dict_never() -> None: - """Test from_azure_ai_tools converts MCP dict require_approval with 'never' key.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": {"never": {"tool_names": ["safe_tool"]}}, - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == {"never": {"tool_names": ["safe_tool"]}} diff --git a/python/packages/azure-cosmos/AGENTS.md b/python/packages/azure-cosmos/AGENTS.md index 7cb0c2c717..9bb7f76da9 100644 --- a/python/packages/azure-cosmos/AGENTS.md +++ b/python/packages/azure-cosmos/AGENTS.md @@ -9,7 +9,7 @@ Azure Cosmos DB history provider integration for Agent Framework. ## Usage ```python -from agent_framework_azure_cosmos import CosmosHistoryProvider +from agent_framework.azure import CosmosHistoryProvider provider = CosmosHistoryProvider( endpoint="https://.documents.azure.com:443/", @@ -24,5 +24,7 @@ Container name is configured on the provider. `session_id` is used as the partit ## Import Path ```python +from agent_framework.azure import CosmosHistoryProvider +# or directly: from agent_framework_azure_cosmos import CosmosHistoryProvider ``` diff --git a/python/packages/azure-cosmos/README.md b/python/packages/azure-cosmos/README.md index 198376bcbb..d2868c78b7 100644 --- a/python/packages/azure-cosmos/README.md +++ b/python/packages/azure-cosmos/README.md @@ -14,7 +14,7 @@ The Azure Cosmos DB integration provides `CosmosHistoryProvider` for persistent ```python from azure.identity.aio import DefaultAzureCredential -from agent_framework_azure_cosmos import CosmosHistoryProvider +from agent_framework.azure import CosmosHistoryProvider provider = CosmosHistoryProvider( endpoint="https://.documents.azure.com:443/", @@ -35,4 +35,13 @@ Container naming behavior: - Container name is configured on the provider (`container_name` or `AZURE_COSMOS_CONTAINER_NAME`) - `session_id` is used as the Cosmos partition key for reads/writes -See `samples/cosmos_history_provider.py` for a runnable package-local example. +See the [conversation samples](../../samples/02-agents/conversations/) for runnable examples, including +[`cosmos_history_provider.py`](../../samples/02-agents/conversations/cosmos_history_provider.py). + +## Import Paths + +```python +from agent_framework.azure import CosmosHistoryProvider +# or directly: +from agent_framework_azure_cosmos import CosmosHistoryProvider +``` diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index 6a61350a9c..d13f285249 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -11,7 +11,7 @@ from collections.abc import Sequence from typing import Any, ClassVar, TypedDict from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message -from agent_framework._sessions import BaseHistoryProvider +from agent_framework._sessions import HistoryProvider from agent_framework._settings import SecretString, load_settings from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -32,8 +32,8 @@ class AzureCosmosHistorySettings(TypedDict, total=False): key: SecretString | None -class CosmosHistoryProvider(BaseHistoryProvider): - """Azure Cosmos DB-backed history provider using BaseHistoryProvider hooks.""" +class CosmosHistoryProvider(HistoryProvider): + """Azure Cosmos DB-backed history provider using HistoryProvider hooks.""" DEFAULT_SOURCE_ID: ClassVar[str] = "azure_cosmos_history" _BATCH_OPERATION_LIMIT: ClassVar[int] = 100 diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index d1bc91d3c1..1dc10cdf4a 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azure-cosmos/samples/README.md b/python/packages/azure-cosmos/samples/README.md deleted file mode 100644 index 082a9c2cfe..0000000000 --- a/python/packages/azure-cosmos/samples/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Azure Cosmos DB Package Samples - -This folder contains samples for `agent-framework-azure-cosmos`. - -| File | Description | -| --- | --- | -| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Demonstrates an Agent using `CosmosHistoryProvider` with `AzureOpenAIResponsesClient` (project endpoint), provider-configured container name, and `session_id` partitioning. | - -## Prerequisites - -- `AZURE_COSMOS_ENDPOINT` -- `AZURE_COSMOS_DATABASE_NAME` -- `AZURE_COSMOS_CONTAINER_NAME` -- `AZURE_COSMOS_KEY` (or equivalent credential flow) - -## Run - -```bash -uv run --directory packages/azure-cosmos python samples/cosmos_history_provider.py -``` diff --git a/python/packages/azure-cosmos/samples/__init__.py b/python/packages/azure-cosmos/samples/__init__.py deleted file mode 100644 index 516b9492f6..0000000000 --- a/python/packages/azure-cosmos/samples/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Samples for the Azure Cosmos history provider package.""" diff --git a/python/packages/azure-cosmos/samples/cosmos_history_provider.py b/python/packages/azure-cosmos/samples/cosmos_history_provider.py deleted file mode 100644 index ff6138c1e5..0000000000 --- a/python/packages/azure-cosmos/samples/cosmos_history_provider.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# ruff: noqa: T201 - -import asyncio -import os - -from agent_framework.azure import AzureOpenAIResponsesClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -from agent_framework_azure_cosmos import CosmosHistoryProvider - -# Load environment variables from .env file. -load_dotenv() - -""" -This sample demonstrates CosmosHistoryProvider as an agent context provider. - -Key components: -- AzureOpenAIResponsesClient configured with an Azure AI project endpoint -- CosmosHistoryProvider configured for Cosmos DB-backed message history -- Provider-configured container name with session_id as partition key - -Environment variables: - AZURE_AI_PROJECT_ENDPOINT - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME - AZURE_COSMOS_ENDPOINT - AZURE_COSMOS_DATABASE_NAME - AZURE_COSMOS_CONTAINER_NAME -Optional: - AZURE_COSMOS_KEY -""" - - -async def main() -> None: - """Run the Cosmos history provider sample with an Agent.""" - project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT") - deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") - cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") - cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") - cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") - cosmos_key = os.getenv("AZURE_COSMOS_KEY") - - if ( - not project_endpoint - or not deployment_name - or not cosmos_endpoint - or not cosmos_database_name - or not cosmos_container_name - ): - print( - "Please set AZURE_AI_PROJECT_ENDPOINT, AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME, " - "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." - ) - return - - # 1. Create an Azure credential and Responses client using project endpoint auth. - async with AzureCliCredential() as credential: - client = AzureOpenAIResponsesClient( - project_endpoint=project_endpoint, - deployment_name=deployment_name, - credential=credential, - ) - - # 2. Create an agent that uses the history provider as a context provider. - async with ( - CosmosHistoryProvider( - endpoint=cosmos_endpoint, - database_name=cosmos_database_name, - container_name=cosmos_container_name, - credential=cosmos_key or credential, - ) as history_provider, - client.as_agent( - name="CosmosHistoryAgent", - instructions="You are a helpful assistant that remembers prior turns.", - context_providers=[history_provider], - default_options={"store": False}, - ) as agent, - ): - # 3. Create a session (session_id is used as the partition key). - session = agent.create_session() - - # 4. Run a multi-turn conversation; history is persisted by CosmosHistoryProvider. - response1 = await agent.run("My name is Ada and I enjoy distributed systems.", session=session) - print(f"Assistant: {response1.text}") - - response2 = await agent.run("What do you remember about me?", session=session) - print(f"Assistant: {response2.text}") - print(f"Container: {history_provider.container_name}") - - -if __name__ == "__main__": - asyncio.run(main()) - -""" -Sample output: -Assistant: Nice to meet you, Ada! Distributed systems are a fascinating area. -Assistant: You told me your name is Ada and that you enjoy distributed systems. -Container: -""" diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 1c43264398..e1164154a5 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -124,16 +124,17 @@ class AgentFunctionApp(DFAppBase): .. code-block:: python - from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient + from agent_framework.azure import AgentFunctionApp + from agent_framework.openai import OpenAIChatCompletionClient # Create agents with unique names - weather_agent = AzureOpenAIChatClient(...).as_agent( + weather_agent = OpenAIChatCompletionClient(...).as_agent( name="WeatherAgent", instructions="You are a helpful weather agent.", tools=[get_weather], ) - math_agent = AzureOpenAIChatClient(...).as_agent( + math_agent = OpenAIChatCompletionClient(...).as_agent( name="MathAgent", instructions="You are a helpful math assistant.", tools=[calculate], diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py index a8774353ec..6fbdaf44f7 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py @@ -240,11 +240,11 @@ def build_agent_executor_response( Returns: AgentExecutorResponse with reconstructed conversation """ - final_text = response_text + final_text: str = response_text or "" if structured_response: final_text = json.dumps(structured_response) - assistant_message = Message(role="assistant", text=final_text) + assistant_message = Message(role="assistant", contents=[final_text]) agent_response = AgentResponse( messages=[assistant_message], @@ -255,7 +255,7 @@ def build_agent_executor_response( 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", text=previous_message)) + full_conversation.append(Message(role="user", contents=[previous_message])) full_conversation.append(assistant_message) diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 83f48ea169..9d92e51d09 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ 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.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/azurefunctions/tests/integration_tests/.env.example b/python/packages/azurefunctions/tests/integration_tests/.env.example index 072a0de92c..75baa4aa27 100644 --- a/python/packages/azurefunctions/tests/integration_tests/.env.example +++ b/python/packages/azurefunctions/tests/integration_tests/.env.example @@ -1,6 +1,6 @@ # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_MODEL=your-deployment-name FUNCTIONS_WORKER_RUNTIME=python # Azure Functions Configuration diff --git a/python/packages/azurefunctions/tests/integration_tests/README.md b/python/packages/azurefunctions/tests/integration_tests/README.md index 8b1f05f6b0..291d7347f3 100644 --- a/python/packages/azurefunctions/tests/integration_tests/README.md +++ b/python/packages/azurefunctions/tests/integration_tests/README.md @@ -14,7 +14,7 @@ cp .env.example .env Required variables: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` - `AZURE_OPENAI_API_KEY` - `AzureWebJobsStorage` - `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` diff --git a/python/packages/azurefunctions/tests/integration_tests/conftest.py b/python/packages/azurefunctions/tests/integration_tests/conftest.py index 98ce922f7e..4f180d4921 100644 --- a/python/packages/azurefunctions/tests/integration_tests/conftest.py +++ b/python/packages/azurefunctions/tests/integration_tests/conftest.py @@ -115,7 +115,7 @@ def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]: os.getenv("FOUNDRY_MODEL", "").strip() ) has_azure_openai_config = bool(os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()) and bool( - os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "").strip() + os.getenv("AZURE_OPENAI_MODEL", "").strip() ) if not has_foundry_config and not has_azure_openai_config: return ( @@ -339,7 +339,7 @@ def _load_and_validate_env(sample_path: Path) -> None: "FUNCTIONS_WORKER_RUNTIME", ] if sample_path.name == "11_workflow_parallel": - required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"]) + required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"]) else: required_env_vars.extend(["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"]) diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py index 03084d5ada..61518fa44b 100644 --- a/python/packages/azurefunctions/tests/test_app.py +++ b/python/packages/azurefunctions/tests/test_app.py @@ -357,7 +357,7 @@ class TestAgentEntityOperations: """Test that entity can run agent operation.""" mock_agent = Mock() mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[Message(role="assistant", text="Test response")]) + return_value=AgentResponse(messages=[Message(role="assistant", contents=["Test response"])]) ) entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123")) @@ -374,7 +374,9 @@ class TestAgentEntityOperations: 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", text="Response 1")])) + 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")) @@ -406,7 +408,9 @@ class TestAgentEntityOperations: 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", text="Response")])) + mock_agent.run = AsyncMock( + return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) + ) entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) @@ -445,7 +449,9 @@ class TestAgentEntityFactory: 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", text="Response")])) + mock_agent.run = AsyncMock( + return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) + ) entity_function = create_agent_entity(mock_agent) @@ -470,7 +476,9 @@ class TestAgentEntityFactory: 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", text="Response")])) + mock_agent.run = AsyncMock( + return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) + ) entity_function = create_agent_entity(mock_agent) diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py index a9db60c9dc..bfd17ba176 100644 --- a/python/packages/azurefunctions/tests/test_entities.py +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -19,7 +19,9 @@ 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", text=text) if text is not None else Message(role="assistant", text="") + message = ( + Message(role="assistant", contents=[text]) if text is not None else Message(role="assistant", contents=[""]) + ) return AgentResponse(messages=[message]) diff --git a/python/packages/azurefunctions/tests/test_func_utils.py b/python/packages/azurefunctions/tests/test_func_utils.py index 9155bad33e..6110c0f895 100644 --- a/python/packages/azurefunctions/tests/test_func_utils.py +++ b/python/packages/azurefunctions/tests/test_func_utils.py @@ -206,7 +206,7 @@ class TestSerializationRoundtrip: def test_roundtrip_chat_message(self) -> None: """Test Message survives encode → decode roundtrip.""" - original = Message(role="user", text="Hello") + original = Message(role="user", contents=["Hello"]) encoded = serialize_value(original) decoded = deserialize_value(encoded) @@ -216,7 +216,7 @@ class TestSerializationRoundtrip: def test_roundtrip_agent_executor_request(self) -> None: """Test AgentExecutorRequest with nested Messages roundtrips.""" original = AgentExecutorRequest( - messages=[Message(role="user", text="Hi")], + messages=[Message(role="user", contents=["Hi"])], should_respond=True, ) encoded = serialize_value(original) @@ -231,8 +231,8 @@ class TestSerializationRoundtrip: """Test AgentExecutorResponse with nested AgentResponse roundtrips.""" original = AgentExecutorResponse( executor_id="test_exec", - agent_response=AgentResponse(messages=[Message(role="assistant", text="Reply")]), - full_conversation=[Message(role="assistant", text="Reply")], + agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Reply"])]), + full_conversation=[Message(role="assistant", contents=["Reply"])], ) encoded = serialize_value(original) decoded = deserialize_value(encoded) @@ -272,8 +272,8 @@ class TestSerializationRoundtrip: def test_roundtrip_list_of_objects(self) -> None: """Test list of typed objects roundtrips.""" original = [ - Message(role="user", text="Q"), - Message(role="assistant", text="A"), + Message(role="user", contents=["Q"]), + Message(role="assistant", contents=["A"]), ] encoded = serialize_value(original) decoded = deserialize_value(encoded) @@ -284,7 +284,7 @@ class TestSerializationRoundtrip: 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", text="Hi")} + original = {"count": 42, "msg": Message(role="user", contents=["Hi"])} encoded = serialize_value(original) decoded = deserialize_value(encoded) diff --git a/python/packages/azurefunctions/tests/test_orchestration.py b/python/packages/azurefunctions/tests/test_orchestration.py index 45e57f0dcf..a4ff77d89f 100644 --- a/python/packages/azurefunctions/tests/test_orchestration.py +++ b/python/packages/azurefunctions/tests/test_orchestration.py @@ -155,7 +155,7 @@ class TestAgentResponseHelpers: # Simulate successful entity task completion entity_task.state = TaskState.SUCCEEDED - entity_task.result = AgentResponse(messages=[Message(role="assistant", text="Test response")]).to_dict() + 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() @@ -197,7 +197,9 @@ class TestAgentResponseHelpers: # Simulate successful entity task with JSON response entity_task.state = TaskState.SUCCEEDED - entity_task.result = AgentResponse(messages=[Message(role="assistant", text='{"answer": "42"}')]).to_dict() + 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() diff --git a/python/packages/azurefunctions/tests/test_workflow.py b/python/packages/azurefunctions/tests/test_workflow.py index baba1c2602..f68af16902 100644 --- a/python/packages/azurefunctions/tests/test_workflow.py +++ b/python/packages/azurefunctions/tests/test_workflow.py @@ -177,10 +177,10 @@ class TestBuildAgentExecutorResponse: # Create a previous response with conversation history previous = AgentExecutorResponse( executor_id="prev", - agent_response=AgentResponse(messages=[Message(role="assistant", text="Previous")]), + agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Previous"])]), full_conversation=[ - Message(role="user", text="First"), - Message(role="assistant", text="Previous"), + Message(role="user", contents=["First"]), + Message(role="assistant", contents=["Previous"]), ], ) @@ -211,8 +211,8 @@ class TestExtractMessageContent: """Test extracting from AgentExecutorResponse with text.""" response = AgentExecutorResponse( executor_id="exec", - agent_response=AgentResponse(messages=[Message(role="assistant", text="Response text")]), - full_conversation=[Message(role="assistant", text="Response text")], + agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Response text"])]), + full_conversation=[Message(role="assistant", contents=["Response text"])], ) result = _extract_message_content(response) @@ -225,13 +225,13 @@ class TestExtractMessageContent: executor_id="exec", agent_response=AgentResponse( messages=[ - Message(role="user", text="First"), - Message(role="assistant", text="Last message"), + Message(role="user", contents=["First"]), + Message(role="assistant", contents=["Last message"]), ] ), full_conversation=[ - Message(role="user", text="First"), - Message(role="assistant", text="Last message"), + Message(role="user", contents=["First"]), + Message(role="assistant", contents=["Last message"]), ], ) @@ -244,8 +244,8 @@ class TestExtractMessageContent: """Test extracting from AgentExecutorRequest.""" request = AgentExecutorRequest( messages=[ - Message(role="user", text="First"), - Message(role="user", text="Last request"), + Message(role="user", contents=["First"]), + Message(role="user", contents=["Last request"]), ] ) diff --git a/python/packages/bedrock/AGENTS.md b/python/packages/bedrock/AGENTS.md index 0ab072ed24..00245229f5 100644 --- a/python/packages/bedrock/AGENTS.md +++ b/python/packages/bedrock/AGENTS.md @@ -14,7 +14,7 @@ Integration with AWS Bedrock for LLM inference. ```python from agent_framework.amazon import BedrockChatClient -client = BedrockChatClient(model_id="anthropic.claude-3-sonnet-20240229-v1:0") +client = BedrockChatClient(model="anthropic.claude-3-sonnet-20240229-v1:0") response = await client.get_response("Hello") ``` diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 0aefbe12f3..3606cdf26b 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -101,7 +101,7 @@ class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], t Keys: # Inherited from ChatOptions (mapped to Bedrock): - model_id: The Bedrock model identifier, + model: The Bedrock model identifier, translates to ``modelId`` in Bedrock API. temperature: Sampling temperature, translates to ``inferenceConfig.temperature``. @@ -175,7 +175,7 @@ class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], t BEDROCK_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "modelId", + "model": "modelId", "max_tokens": "maxTokens", "top_p": "topP", "stop": "stopSequences", @@ -209,7 +209,7 @@ class BedrockSettings(TypedDict, total=False): """Bedrock configuration settings pulled from environment variables or .env files.""" region: str | None - chat_model_id: str | None + chat_model: str | None access_key: SecretString | None secret_key: SecretString | None session_token: SecretString | None @@ -230,7 +230,7 @@ class BedrockChatClient( self, *, region: str | None = None, - model_id: str | None = None, + model: str | None = None, access_key: str | None = None, secret_key: str | None = None, session_token: str | None = None, @@ -246,7 +246,7 @@ class BedrockChatClient( Args: region: Region to send Bedrock requests to; falls back to BEDROCK_REGION. - model_id: Default model identifier; falls back to BEDROCK_CHAT_MODEL_ID. + model: Default model identifier; falls back to BEDROCK_CHAT_MODEL. access_key: Optional AWS access key for manual credential injection. secret_key: Optional AWS secret key paired with ``access_key``. session_token: Optional AWS session token for temporary credentials. @@ -264,7 +264,7 @@ class BedrockChatClient( from agent_framework.amazon import BedrockChatClient # Basic usage with default credentials - client = BedrockChatClient(model_id="") + client = BedrockChatClient(model="") # Using custom ChatOptions with type safety: from typing import TypedDict @@ -275,14 +275,14 @@ class BedrockChatClient( my_custom_option: str - client = BedrockChatClient[MyOptions](model_id="") + client = BedrockChatClient[MyOptions](model="") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ settings = load_settings( BedrockSettings, env_prefix="BEDROCK_", region=region, - chat_model_id=model_id, + chat_model=model, access_key=access_key, secret_key=secret_key, session_token=session_token, @@ -290,7 +290,7 @@ class BedrockChatClient( env_file_encoding=env_file_encoding, ) region = settings.get("region") or DEFAULT_REGION - chat_model_id = settings.get("chat_model_id") + chat_model = settings.get("chat_model") if client: self._bedrock_client = client @@ -307,7 +307,7 @@ class BedrockChatClient( middleware=middleware, function_invocation_configuration=function_invocation_configuration, ) - self.model_id = chat_model_id + self.model = chat_model self.region = region @staticmethod @@ -355,7 +355,7 @@ class BedrockChatClient( yield ChatResponseUpdate( response_id=parsed_response.response_id, contents=contents, - model_id=parsed_response.model_id, + model=parsed_response.model, finish_reason=finish_reason, raw_representation=parsed_response.raw_representation, ) @@ -375,10 +375,10 @@ class BedrockChatClient( options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: - model_id = options.get("model_id") or self.model_id - if not model_id: + model = options.get("model") or self.model + if not model: raise ValueError( - "Bedrock model_id is required. Set via chat options or BEDROCK_CHAT_MODEL_ID environment variable." + "Bedrock model is required. Set via chat options or BEDROCK_CHAT_MODEL environment variable." ) system_prompts, conversation = self._prepare_bedrock_messages(messages) @@ -389,7 +389,7 @@ class BedrockChatClient( system_prompts = [{"text": instructions}, *system_prompts] run_options: dict[str, Any] = { - "modelId": model_id, + "modelId": model, "messages": conversation, "inferenceConfig": {"maxTokens": options.get("max_tokens", DEFAULT_MAX_TOKENS)}, } @@ -633,12 +633,12 @@ class BedrockChatClient( usage_details = self._parse_usage(usage_source) finish_reason = self._map_finish_reason(output.get("completionReason") or response.get("stopReason")) response_id = response.get("responseId") or message.get("id") - model_id = response.get("modelId") or output.get("modelId") or self.model_id + model = response.get("modelId") or output.get("modelId") or self.model return ChatResponse( response_id=response_id, messages=[chat_message], usage_details=usage_details, - model_id=model_id, + model=model, finish_reason=finish_reason, raw_representation=response, ) diff --git a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py index 3161ed4c88..99250b8248 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py @@ -39,7 +39,7 @@ class BedrockEmbeddingSettings(TypedDict, total=False): """Bedrock embedding settings.""" region: str | None - embedding_model_id: str | None + embedding_model: str | None access_key: SecretString | None secret_key: SecretString | None session_token: SecretString | None @@ -56,7 +56,7 @@ class BedrockEmbeddingOptions(EmbeddingGenerationOptions, total=False): from agent_framework_bedrock import BedrockEmbeddingOptions options: BedrockEmbeddingOptions = { - "model_id": "amazon.titan-embed-text-v2:0", + "model": "amazon.titan-embed-text-v2:0", "dimensions": 1024, "normalize": True, } @@ -80,8 +80,8 @@ class RawBedrockEmbeddingClient( """Raw Bedrock embedding client without telemetry. Keyword Args: - model_id: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). - Can also be set via environment variable BEDROCK_EMBEDDING_MODEL_ID. + model: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). + Can also be set via environment variable BEDROCK_EMBEDDING_MODEL. region: AWS region. Will try to load from BEDROCK_REGION env var, if not set, the regular Boto3 configuration/loading applies (which may include other env vars, config files, or instance metadata). @@ -98,7 +98,7 @@ class RawBedrockEmbeddingClient( self, *, region: str | None = None, - model_id: str | None = None, + model: str | None = None, access_key: str | None = None, secret_key: str | None = None, session_token: str | None = None, @@ -112,9 +112,9 @@ class RawBedrockEmbeddingClient( settings = load_settings( BedrockEmbeddingSettings, env_prefix="BEDROCK_", - required_fields=["embedding_model_id"], + required_fields=["embedding_model"], region=region, - embedding_model_id=model_id, + embedding_model=model, access_key=access_key, secret_key=secret_key, session_token=session_token, @@ -143,7 +143,7 @@ class RawBedrockEmbeddingClient( config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), ) - self.model_id: str = settings["embedding_model_id"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] + self.model: str = settings["embedding_model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] self.region = resolved_region super().__init__(additional_properties=additional_properties) @@ -170,15 +170,15 @@ class RawBedrockEmbeddingClient( Generated embeddings with usage metadata. Raises: - ValueError: If model_id is not provided or values is empty. + ValueError: If model is not provided or values is empty. """ if not values: return GeneratedEmbeddings([], options=options) opts: dict[str, Any] = dict(options) if options else {} - model = opts.get("model_id") or self.model_id + model = opts.get("model") or self.model if not model: - raise ValueError("model_id is required") + raise ValueError("model is required") embedding_results = await asyncio.gather( *(self._generate_embedding_for_text(opts, model, text) for text in values) @@ -218,7 +218,7 @@ class RawBedrockEmbeddingClient( embedding = Embedding( vector=response_body["embedding"], dimensions=len(response_body["embedding"]), - model_id=model, + model=model, ) input_tokens = int(response_body.get("inputTextTokenCount", 0)) return embedding, input_tokens @@ -234,8 +234,8 @@ class BedrockEmbeddingClient( Uses the Amazon Titan Embeddings model via Bedrock's invoke_model API. Keyword Args: - model_id: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). - Can also be set via environment variable BEDROCK_EMBEDDING_MODEL_ID. + model: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). + Can also be set via environment variable BEDROCK_EMBEDDING_MODEL. region: AWS region. Defaults to "us-east-1". Can also be set via environment variable BEDROCK_REGION. access_key: AWS access key for manual credential injection. @@ -253,7 +253,7 @@ class BedrockEmbeddingClient( # Using default AWS credentials client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", ) # Generate embeddings @@ -267,7 +267,7 @@ class BedrockEmbeddingClient( self, *, region: str | None = None, - model_id: str | None = None, + model: str | None = None, access_key: str | None = None, secret_key: str | None = None, session_token: str | None = None, @@ -281,7 +281,7 @@ class BedrockEmbeddingClient( """Initialize a Bedrock embedding client.""" super().__init__( region=region, - model_id=model_id, + model=model, access_key=access_key, secret_key=secret_key, session_token=session_token, diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index 1583d44098..ac81bc66d2 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py b/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py index f6f96254a7..afb32c4158 100644 --- a/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py +++ b/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py @@ -39,17 +39,17 @@ async def test_bedrock_embedding_construction() -> None: """Test construction with explicit parameters.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) - assert client.model_id == "amazon.titan-embed-text-v2:0" + assert client.model == "amazon.titan-embed-text-v2:0" assert client.region == "us-west-2" async def test_bedrock_embedding_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - """Test that missing model_id raises an error.""" - monkeypatch.delenv("BEDROCK_EMBEDDING_MODEL_ID", raising=False) + """Test that missing model raises an error.""" + monkeypatch.delenv("BEDROCK_EMBEDDING_MODEL", raising=False) from agent_framework.exceptions import SettingNotFoundError with pytest.raises(SettingNotFoundError): @@ -60,7 +60,7 @@ async def test_bedrock_embedding_get_embeddings() -> None: """Test generating embeddings via the Bedrock invoke_model API.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) @@ -71,7 +71,7 @@ async def test_bedrock_embedding_get_embeddings() -> None: assert len(result) == 2 assert len(result[0].vector) == 3 assert len(result[1].vector) == 3 - assert result[0].model_id == "amazon.titan-embed-text-v2:0" + assert result[0].model == "amazon.titan-embed-text-v2:0" assert result.usage == {"input_token_count": 10} # Two calls since Titan processes one input at a time @@ -84,7 +84,7 @@ async def test_bedrock_embedding_get_embeddings_empty_input() -> None: """Test generating embeddings with empty input.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) @@ -100,7 +100,7 @@ async def test_bedrock_embedding_get_embeddings_with_options() -> None: """Test generating embeddings with custom options.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) @@ -120,16 +120,16 @@ async def test_bedrock_embedding_get_embeddings_with_options() -> None: async def test_bedrock_embedding_get_embeddings_no_model_raises() -> None: - """Test that missing model_id at call time raises ValueError.""" + """Test that missing model at call time raises ValueError.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) - client.model_id = None # type: ignore[assignment] + client.model = None # type: ignore[assignment] - with pytest.raises(ValueError, match="model_id is required"): + with pytest.raises(ValueError, match="model is required"): await client.get_embeddings(["hello"]) @@ -137,7 +137,7 @@ async def test_bedrock_embedding_default_region() -> None: """Test that default region is us-east-1.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", client=stub, ) assert client.region == "us-east-1" @@ -146,7 +146,7 @@ async def test_bedrock_embedding_default_region() -> None: # region: Integration Tests skip_if_bedrock_embedding_integration_tests_disabled = pytest.mark.skipif( - os.getenv("BEDROCK_EMBEDDING_MODEL_ID", "") in ("", "test-model") + os.getenv("BEDROCK_EMBEDDING_MODEL", "") in ("", "test-model") or not (os.getenv("AWS_ACCESS_KEY_ID") or os.getenv("BEDROCK_ACCESS_KEY")), reason="No real Bedrock embedding model or AWS credentials provided; skipping integration tests.", ) diff --git a/python/packages/bedrock/tests/test_bedrock_client.py b/python/packages/bedrock/tests/test_bedrock_client.py index 1566bff234..fbc241b24c 100644 --- a/python/packages/bedrock/tests/test_bedrock_client.py +++ b/python/packages/bedrock/tests/test_bedrock_client.py @@ -34,7 +34,7 @@ class _StubBedrockRuntime: def _make_client() -> BedrockChatClient: """Create a BedrockChatClient with a stub runtime for unit tests.""" return BedrockChatClient( - model_id="amazon.titan-text", + model="amazon.titan-text", region="us-west-2", client=_StubBedrockRuntime(), ) @@ -43,7 +43,7 @@ def _make_client() -> BedrockChatClient: async def test_get_response_invokes_bedrock_runtime() -> None: stub = _StubBedrockRuntime() client = BedrockChatClient( - model_id="amazon.titan-text", + model="amazon.titan-text", region="us-west-2", client=stub, ) @@ -65,7 +65,7 @@ async def test_get_response_invokes_bedrock_runtime() -> None: def test_build_request_requires_non_system_messages() -> None: client = BedrockChatClient( - model_id="amazon.titan-text", + model="amazon.titan-text", region="us-west-2", client=_StubBedrockRuntime(), ) diff --git a/python/packages/bedrock/tests/test_bedrock_settings.py b/python/packages/bedrock/tests/test_bedrock_settings.py index 85e417602a..c828c2dc94 100644 --- a/python/packages/bedrock/tests/test_bedrock_settings.py +++ b/python/packages/bedrock/tests/test_bedrock_settings.py @@ -24,7 +24,7 @@ class _WeatherArgs(BaseModel): def _build_client() -> BedrockChatClient: fake_runtime = MagicMock() fake_runtime.converse.return_value = {} - return BedrockChatClient(model_id="test-model", client=fake_runtime) + return BedrockChatClient(model="test-model", client=fake_runtime) def _dummy_weather(location: str) -> str: # pragma: no cover - helper @@ -33,10 +33,10 @@ def _dummy_weather(location: str) -> str: # pragma: no cover - helper def test_settings_load_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BEDROCK_REGION", "us-west-2") - monkeypatch.setenv("BEDROCK_CHAT_MODEL_ID", "anthropic.claude-v2") + monkeypatch.setenv("BEDROCK_CHAT_MODEL", "anthropic.claude-v2") settings = load_settings(BedrockSettings, env_prefix="BEDROCK_") assert settings["region"] == "us-west-2" - assert settings["chat_model_id"] == "anthropic.claude-v2" + assert settings["chat_model"] == "anthropic.claude-v2" def test_build_request_includes_tool_config() -> None: diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index 874efaa097..7caacd4d82 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -64,7 +64,7 @@ from fastapi import FastAPI, Request from fastapi.responses import Response, StreamingResponse from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.chatkit import simple_to_agent_input, stream_agent_response from chatkit.server import ChatKitServer @@ -75,7 +75,7 @@ from your_store import YourStore # type: ignore[import-not-found] # Replace wi # Define your agent with tools agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", tools=[], # Add your tools here ) diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py index 5aa953e25a..e92583188c 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -102,7 +102,7 @@ class ThreadItemConverter: # If only text and no attachments, use text parameter for simplicity if text_content.strip() and not data_contents: - user_message = Message(role="user", text=text_content.strip()) + user_message = Message(role="user", contents=[text_content.strip()]) else: # Build contents list with both text and attachments contents: list[Content] = [] @@ -116,7 +116,7 @@ class ThreadItemConverter: if item.quoted_text and is_last_message: quoted_context = Message( role="user", - text=f"The user is referring to this in particular:\n{item.quoted_text}", + contents=[f"The user is referring to this in particular:\n{item.quoted_text}"], ) # Prepend quoted context before the main message messages.insert(0, quoted_context) @@ -211,9 +211,9 @@ class ThreadItemConverter: content="User's email: user@example.com", ) message = converter.hidden_context_to_input(hidden_item) - # Returns: Message(role=SYSTEM, text="User's email: ...") + # Returns: Message(role=SYSTEM, contents=["User's email: ..."]) """ - return Message(role="system", text=f"{item.content}") + return Message(role="system", contents=[f"{item.content}"]) def tag_to_message_content(self, tag: UserMessageTagContent) -> Content: """Convert a ChatKit tag (@-mention) to Agent Framework content. @@ -292,7 +292,7 @@ class ThreadItemConverter: f"A message was displayed to the user that the following task was performed:\n\n{task_text}\n" ) - return Message(role="user", text=text) + return Message(role="user", contents=[text]) def workflow_to_input(self, item: WorkflowItem) -> Message | list[Message] | None: """Convert a ChatKit WorkflowItem to Agent Framework Message(s). @@ -347,7 +347,7 @@ class ThreadItemConverter: f"\n{task_text}\n" ) - messages.append(Message(role="user", text=text)) + messages.append(Message(role="user", contents=[text])) return messages if messages else None @@ -389,7 +389,7 @@ class ThreadItemConverter: try: widget_json = item.widget.model_dump_json(exclude_unset=True, exclude_none=True) text = f"The following graphical UI widget (id: {item.id}) was displayed to the user:{widget_json}" - return Message(role="user", text=text) + return Message(role="user", contents=[text]) except Exception: # If JSON serialization fails, skip the widget return None @@ -415,7 +415,7 @@ class ThreadItemConverter: if not text_parts: return None - return Message(role="assistant", text="".join(text_parts)) + return Message(role="assistant", contents=["".join(text_parts)]) async def client_tool_call_to_input(self, item: ClientToolCallItem) -> Message | list[Message] | None: """Convert a ChatKit ClientToolCallItem to Agent Framework Message(s). diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 7da827adcb..2cdce32a0d 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index dd30a3b2d2..fcc7151342 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -16,8 +16,8 @@ from agent_framework import ( AgentRunInputs, AgentSession, BaseAgent, - BaseContextProvider, Content, + ContextProvider, FunctionTool, Message, ResponseStream, @@ -223,7 +223,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, default_options: OptionsT | MutableMapping[str, Any] | None = None, diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index b78e00cf42..7740d553a3 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index fc2a35c72b..56a9c89081 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -11,8 +11,8 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, BaseAgent, - BaseContextProvider, Content, + ContextProvider, Message, ResponseStream, normalize_messages, @@ -60,7 +60,7 @@ class CopilotStudioAgent(BaseAgent): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: list[AgentMiddlewareTypes] | None = None, environment_id: str | None = None, agent_identifier: str | None = None, diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 7a29537b7e..c9f6ac7b01 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 859858f0ef..d6940289ac 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -61,8 +61,8 @@ agent_framework/ - **`AgentSession`** - Manages conversation state and session metadata - **`SessionContext`** - Context object for session-scoped data during agent runs -- **`BaseContextProvider`** - Base class for context providers (RAG, memory systems) -- **`BaseHistoryProvider`** - Base class for conversation history storage +- **`ContextProvider`** - Base class for context providers (RAG, memory systems) +- **`HistoryProvider`** - Base class for conversation history storage ### Skills (`_skills.py`) @@ -70,7 +70,7 @@ agent_framework/ - **`SkillResource`** - Named supplementary content attached to a skill; holds either static `content` or a dynamic `function` (sync or async). Exactly one must be provided. - **`SkillScript`** - An executable script attached to a skill; holds either an inline `function` (code-defined, runs in-process) or a `path` to a file on disk (file-based, delegated to a runner). Exactly one must be provided. - **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner. -- **`SkillsProvider`** - Context provider (extends `BaseContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts. +- **`SkillsProvider`** - Context provider (extends `ContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts. ### Workflows (`_workflows/`) @@ -82,13 +82,12 @@ agent_framework/ ### OpenAI (`openai/`) -- **`OpenAIChatClient`** - Chat client for OpenAI API -- **`OpenAIResponsesClient`** - Client for OpenAI Responses API +- **`OpenAIChatClient`** - Chat client for the OpenAI Responses API +- **`OpenAIChatCompletionClient`** - Chat client for the OpenAI Chat Completions API -### Azure OpenAI (`azure/`) +### Foundry (`foundry/`) -- **`AzureOpenAIChatClient`** - Chat client for Azure OpenAI -- **`AzureOpenAIResponsesClient`** - Client for Azure OpenAI Responses API +- **`FoundryChatClient`** - Chat client for Azure AI Foundry project endpoints ## Key Patterns @@ -137,7 +136,7 @@ from agent_framework import BaseChatClient, ChatResponse, Message class MyClient(BaseChatClient): async def _inner_get_response(self, *, messages, options, **kwargs) -> ChatResponse: # Call your LLM here - return ChatResponse(messages=[Message(role="assistant", text="Hi!")]) + return ChatResponse(messages=[Message(role="assistant", contents=["Hi!"])]) async def _inner_get_streaming_response(self, *, messages, options, **kwargs): yield ChatResponseUpdate(...) diff --git a/python/packages/core/README.md b/python/packages/core/README.md index 36f0f6e02c..b4039e6950 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -5,7 +5,7 @@ Highlights - Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems - Multi-Agent Orchestration: Group chat, sequential, concurrent, and handoff patterns - Plugin Ecosystem: Extend with native functions, OpenAPI, Model Context Protocol (MCP), and more -- LLM Support: OpenAI, Azure OpenAI, Azure AI, and more +- LLM Support: OpenAI, Foundry, Anthropic, and more - Runtime Support: In-process and distributed agent execution - Multimodal: Text, vision, and function calling - Cross-Platform: .NET and Python implementations @@ -13,9 +13,11 @@ Highlights ## Quick Install ```bash -pip install agent-framework-core --pre +pip install agent-framework-core # Optional: Add Azure AI Foundry integration -pip install agent-framework-foundry --pre +pip install agent-framework-foundry +# Optional: Add OpenAI integration +pip install agent-framework-openai ``` Supported Platforms: @@ -25,35 +27,33 @@ Supported Platforms: ## 1. Setup API Keys -Set as environment variables, or create a .env file at your project root: +Depending on the client you want to use, there are various environment variables you can set to configure the chat clients. This can be done in the environment itself, or with a `.env` file in your project root, some examples of environment variables include: ```bash +FOUNDRY_PROJECT_ENDPOINT=... +FOUNDRY_MODEL=... +... OPENAI_API_KEY=sk-... +OPENAI_CHAT_COMPLETION_MODEL=... OPENAI_CHAT_MODEL=... -OPENAI_RESPONSES_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=... -... -FOUNDRY_PROJECT_ENDPOINT=... -FOUNDRY_MODEL=... +AZURE_OPENAI_MODEL=... ``` You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: ```python -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient -client = AzureOpenAIChatClient( +client = OpenAIChatClient( api_key="", - endpoint="", - deployment_name="", - api_version="", + model="", ) ``` -See the following [setup guide](../../samples/01-get-started) for more information. +See the following [getting started samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/01-get-started) for more information. ## 2. Create a Simple Agent @@ -64,22 +64,19 @@ import asyncio from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -async def main(): - agent = Agent( - client=OpenAIChatClient(), - instructions=""" - 1) A robot may not injure a human being... - 2) A robot must obey orders given it by human beings... - 3) A robot must protect its own existence... +agent = Agent( + client=OpenAIChatClient(), + instructions=""" + 1) A robot may not injure a human being... + 2) A robot must obey orders given it by human beings... + 3) A robot must protect its own existence... - Give me the TLDR in exactly 5 words. - """ - ) + Give me the TLDR in exactly 5 words. + """ +) - result = await agent.run("Summarize the Three Laws of Robotics") - print(result) - -asyncio.run(main()) +result = asyncio.run(agent.run("Summarize the Three Laws of Robotics")) +print(result) # Output: Protect humans, obey, self-preserve, prioritized. ``` @@ -95,12 +92,10 @@ from agent_framework import Message, Role async def main(): client = OpenAIChatClient() - messages = [ + response = await client.get_response([ Message("system", ["You are a helpful assistant."]), Message("user", ["Write a haiku about Agent Framework."]) - ] - - response = await client.get_response(messages) + ]) print(response.messages[0].text) """ @@ -122,13 +117,12 @@ Enhance your agent with custom tools and function calling: import asyncio from typing import Annotated from random import randint -from pydantic import Field from agent_framework import Agent from agent_framework.openai import OpenAIChatClient def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], + location: Annotated[str, "The location to get the weather for."], ) -> str: """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] @@ -161,7 +155,7 @@ async def main(): asyncio.run(main()) ``` -You can explore additional agent samples [here](../../samples/02-agents). +You can explore additional agent samples [here](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents). ## 5. Multi-Agent Orchestration @@ -213,14 +207,14 @@ if __name__ == "__main__": asyncio.run(main()) ``` -**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](../../samples/03-workflows/orchestrations). +**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/03-workflows/orchestrations). ## More Examples & Samples -- [Getting Started with Agents](../../samples/02-agents): Basic agent creation and tool usage -- [Chat Client Examples](../../samples/02-agents/chat_client): Direct chat client usage patterns -- [Azure AI Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/azure-ai): Azure AI integration -- [Workflows Samples](../../samples/03-workflows): Advanced multi-agent patterns +- [Getting Started with Agents](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents): Basic agent creation and tool usage +- [Chat Client Examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents/chat_client): Direct chat client usage patterns +- [Foundry Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/foundry): Foundry integration +- [Workflows Samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/03-workflows): Advanced multi-agent patterns ## Agent Framework Documentation @@ -228,4 +222,4 @@ if __name__ == "__main__": - [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python) - [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet) - [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design) -- [Learn Documentation](https://learn.microsoft.com/en-us/agent-framework/user-guide/workflows/orchestrations/overview) +- [Learn Documentation](https://learn.microsoft.com/agent-framework/) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index a9e4245e77..497fc1496d 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -78,6 +78,7 @@ from ._evaluation import ( tool_called_check, tool_calls_present, ) +from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool from ._middleware import ( AgentContext, @@ -101,8 +102,8 @@ from ._middleware import ( ) from ._sessions import ( AgentSession, - BaseContextProvider, - BaseHistoryProvider, + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, SessionContext, register_state_type, @@ -277,9 +278,7 @@ __all__ = [ "Annotation", "BaseAgent", "BaseChatClient", - "BaseContextProvider", "BaseEmbeddingClient", - "BaseHistoryProvider", "Case", "CharacterEstimatorTokenizer", "ChatAndFunctionMiddlewareTypes", @@ -295,6 +294,7 @@ __all__ = [ "CompactionProvider", "CompactionStrategy", "Content", + "ContextProvider", "ContinuationToken", "ConversationSplit", "ConversationSplitter", @@ -314,6 +314,7 @@ __all__ = [ "Evaluator", "Executor", "ExpectedToolCall", + "ExperimentalFeature", "FanInEdgeGroup", "FanOutEdgeGroup", "FileCheckpointStorage", @@ -329,6 +330,7 @@ __all__ = [ "FunctionTool", "GeneratedEmbeddings", "GraphConnectivityError", + "HistoryProvider", "InMemoryCheckpointStorage", "InMemoryHistoryProvider", "InProcRunnerContext", @@ -344,6 +346,7 @@ __all__ = [ "OuterFinalT", "OuterUpdateT", "RawAgent", + "ReleaseCandidateFeature", "ResponseStream", "Role", "RoleLiteral", diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 1868742111..585898ae52 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -29,14 +29,16 @@ from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage] from ._clients import BaseChatClient, SupportsChatGetResponse from ._docstrings import apply_layered_docstring from ._mcp import LOG_LEVEL_MAPPING, MCPTool -from ._middleware import AgentMiddlewareLayer, FunctionInvocationContext, MiddlewareTypes +from ._middleware import AgentMiddlewareLayer, FunctionInvocationContext, MiddlewareTypes, categorize_middleware from ._serialization import SerializationMixin from ._sessions import ( AgentSession, - BaseContextProvider, - BaseHistoryProvider, + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, + PerServiceCallHistoryPersistingMiddleware, SessionContext, + is_local_history_conversation_id, ) from ._tools import FunctionInvocationLayer, FunctionTool, ToolTypes, normalize_tools from ._types import ( @@ -50,7 +52,7 @@ from ._types import ( map_chat_to_agent_update, normalize_messages, ) -from .exceptions import AgentInvalidResponseException, UserInputRequiredException +from .exceptions import AgentInvalidRequestException, AgentInvalidResponseException, UserInputRequiredException from .observability import AgentTelemetryLayer if sys.version_info >= (3, 13): @@ -98,6 +100,7 @@ def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, A new merged options dict. """ result = dict(base) + for key, value in override.items(): if value is None: continue @@ -166,6 +169,7 @@ class _RunContext(TypedDict): input_messages: Sequence[Message] session_messages: Sequence[Message] agent_name: str + suppress_response_id: bool chat_options: MutableMapping[str, Any] compaction_strategy: CompactionStrategy | None tokenizer: TokenizerProtocol | None @@ -366,6 +370,7 @@ class BaseAgent(SerializationMixin): """ DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"} + require_per_service_call_history_persistence: bool = False def __init__( self, @@ -373,7 +378,7 @@ class BaseAgent(SerializationMixin): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, additional_properties: MutableMapping[str, Any] | None = None, ) -> None: @@ -393,7 +398,7 @@ class BaseAgent(SerializationMixin): self.id = id self.name = name self.description = description - self.context_providers: list[BaseContextProvider] = list(context_providers or []) + self.context_providers: list[ContextProvider] = list(context_providers or []) self.middleware: list[MiddlewareTypes] | None = ( cast(list[MiddlewareTypes], middleware) if middleware is not None else None ) @@ -455,7 +460,12 @@ class BaseAgent(SerializationMixin): if provider_session is None and self.context_providers: provider_session = AgentSession() + per_service_call_history_required = self.require_per_service_call_history_persistence and any( + isinstance(provider, HistoryProvider) for provider in self.context_providers + ) for provider in reversed(self.context_providers): + if per_service_call_history_required and isinstance(provider, HistoryProvider): + continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") await provider.after_run( @@ -587,7 +597,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] from agent_framework.openai import OpenAIChatClient # Create a basic chat agent - client = OpenAIChatClient(model_id="gpt-4") + client = OpenAIChatClient(model="gpt-4") agent = Agent(client=client, name="assistant", description="A helpful assistant") # Run the agent with a simple message @@ -625,7 +635,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] from agent_framework import Agent from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions - client = OpenAIChatClient(model_id="gpt-4o") + client = OpenAIChatClient(model="gpt-4o") agent: Agent[OpenAIChatOptions] = Agent( client=client, name="reasoning-agent", @@ -656,8 +666,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] description: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsCoT | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: MutableMapping[str, Any] | None = None, @@ -675,9 +686,14 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] description: A brief description of the agent's purpose. context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. + require_per_service_call_history_persistence: When True, history providers are invoked + around each model call instead of once per ``run()`` when the service + is not already storing history. If service-side storage is active for + the run, the agent skips local history providers and relies on the + service-managed conversation instead. default_options: A TypedDict containing chat options. When using a typed agent like ``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for - provider-specific options including temperature, max_tokens, model_id, + provider-specific options including temperature, max_tokens, model, tool_choice, and provider-specific options like reasoning_effort. You can also create your own TypedDict for custom chat clients. Note: response_format typing does not flow into run outputs when set via default_options. @@ -706,6 +722,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] ) self.client = client self.compaction_strategy = compaction_strategy + self.require_per_service_call_history_persistence = require_per_service_call_history_persistence self.tokenizer = tokenizer # Get tools from options or named parameter (named param takes precedence) @@ -720,9 +737,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] self.mcp_tools: list[MCPTool] = [tool for tool in normalized_tools if isinstance(tool, MCPTool)] agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)] + model = opts.pop("model", None) or getattr(self.client, "model", None) + # Build chat options dict self.default_options: dict[str, Any] = { - "model_id": opts.pop("model_id", None) or (getattr(self.client, "model_id", None)), "allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None), "conversation_id": opts.pop("conversation_id", None), "frequency_penalty": opts.pop("frequency_penalty", None), @@ -742,6 +760,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "user": opts.pop("user", None), **opts, # Remaining options are provider-specific } + if model is not None: + self.default_options["model"] = model # Remove None values from chat_options self.default_options = {k: v for k, v in self.default_options.items() if v is not None} self._async_exit_stack = AsyncExitStack() @@ -764,6 +784,35 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] await self._async_exit_stack.enter_async_context(context_manager) return self + def _get_history_providers(self) -> list[HistoryProvider]: + return [provider for provider in self.context_providers if isinstance(provider, HistoryProvider)] + + def _resolve_per_service_call_history_providers( + self, + *, + session: AgentSession | None, + options: Mapping[str, Any] | None, + service_stores_history: bool, + ) -> list[HistoryProvider]: + history_providers = self._get_history_providers() + if not self.require_per_service_call_history_persistence or not history_providers: + return [] + + conversation_id = ( + session.service_session_id + if session and session.service_session_id + else cast(str | None, (options or {}).get("conversation_id") or self.default_options.get("conversation_id")) + ) + if service_stores_history: + return [] + + if conversation_id is not None: + raise AgentInvalidRequestException( + "require_per_service_call_history_persistence cannot be used " + "with an existing service-managed conversation." + ) + return history_providers + async def __aexit__( self, exc_type: type[BaseException] | None, @@ -869,7 +918,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tools: The tools to use for this specific run (merged with default tools). options: A TypedDict containing chat options. When using a typed agent like ``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for - provider-specific options including temperature, max_tokens, model_id, + provider-specific options including temperature, max_tokens, model, tool_choice, and provider-specific options like reasoning_effort. compaction_strategy: Optional per-run compaction override passed to ``client.get_response()``. When omitted, the agent-level override @@ -885,97 +934,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] When stream=True: A ResponseStream of AgentResponseUpdate items with ``get_final_response()`` for the final AgentResponse. """ - if not stream: - async def _run_non_streaming() -> AgentResponse[Any]: - ctx = await self._prepare_run_context( - messages=messages, - session=session, - tools=tools, - options=options, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - ) - response = cast( - ChatResponse[Any], - await self.client.get_response( # type: ignore - messages=ctx["session_messages"], - stream=False, - options=ctx["chat_options"], # type: ignore[reportArgumentType] - compaction_strategy=ctx["compaction_strategy"], - tokenizer=ctx["tokenizer"], - function_invocation_kwargs=ctx["function_invocation_kwargs"], - client_kwargs=ctx["client_kwargs"], - ), - ) - - if not response: - raise AgentInvalidResponseException("Chat client did not return a response.") - - await self._finalize_response( - response=response, - agent_name=ctx["agent_name"], - session=ctx["session"], - session_context=ctx["session_context"], - ) - response_format = ctx["chat_options"].get("response_format") - if not ( - response_format is not None - and isinstance(response_format, type) - and issubclass(response_format, BaseModel) - ): - response_format = None - - return AgentResponse( - messages=response.messages, - response_id=response.response_id, - created_at=response.created_at, - usage_details=response.usage_details, - value=response.value, - response_format=response_format, - continuation_token=response.continuation_token, - raw_representation=response, - additional_properties=response.additional_properties, - ) - - return _run_non_streaming() - - # Use a holder to capture the context created during stream initialization - ctx_holder: dict[str, _RunContext | None] = {"ctx": None} - - async def _post_hook(response: AgentResponse) -> None: - ctx = ctx_holder["ctx"] - if ctx is None: - return # No context available (shouldn't happen in normal flow) - - # Update thread with conversation_id derived from streaming raw updates. - # Using response_id here can break function-call continuation for APIs - # where response IDs are not valid conversation handles. - conversation_id = self._extract_conversation_id_from_streaming_response(response) - # Ensure author names are set for all messages - for message in response.messages: - if message.author_name is None: - message.author_name = ctx["agent_name"] - - # Propagate conversation_id back to session from streaming updates. - # For Responses-style APIs this can rotate every turn (response_id-based continuation), - # so refresh when a newer value is returned. - sess = ctx["session"] - if sess and conversation_id and sess.service_session_id != conversation_id: - sess.service_session_id = conversation_id - - # Run after_run providers (reverse order) - session_context = ctx["session_context"] - session_context._response = AgentResponse( # type: ignore[assignment] - messages=response.messages, - response_id=response.response_id, - ) - await self._run_after_providers(session=ctx["session"], context=session_context) - - async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - ctx_holder["ctx"] = await self._prepare_run_context( + async def _prepare_run_context() -> _RunContext: + return await self._prepare_run_context( messages=messages, session=session, tools=tools, @@ -985,55 +946,170 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) - ctx: _RunContext = ctx_holder["ctx"] # type: ignore[assignment] # Safe: we just assigned it + + if not stream: + + async def _run_non_streaming() -> AgentResponse[Any]: + ctx = await _prepare_run_context() + response = await self._call_chat_client(ctx, stream=False) + return await self._parse_non_streaming_response(ctx, response) + + return _run_non_streaming() + + async def _run_streaming() -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + ctx = await _prepare_run_context() + stream_response = self._call_chat_client(ctx, stream=True) + return self._parse_streaming_response(ctx, stream_response) + + return cast( + ResponseStream[AgentResponseUpdate, AgentResponse[Any]], + cast(Any, ResponseStream).from_awaitable(_run_streaming()), + ) + + @overload + def _call_chat_client( + self, + context: _RunContext, + *, + stream: Literal[False], + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def _call_chat_client( + self, + context: _RunContext, + *, + stream: Literal[True], + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + def _call_chat_client( + self, + context: _RunContext, + *, + stream: bool, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Invoke the downstream chat client for a prepared run context.""" + if stream: return self.client.get_response( # type: ignore[call-overload, no-any-return] - messages=ctx["session_messages"], + messages=context["session_messages"], stream=True, - options=ctx["chat_options"], # type: ignore[reportArgumentType] - compaction_strategy=ctx["compaction_strategy"], - tokenizer=ctx["tokenizer"], - function_invocation_kwargs=ctx["function_invocation_kwargs"], - client_kwargs=ctx["client_kwargs"], + options=context["chat_options"], # type: ignore[reportArgumentType] + compaction_strategy=context["compaction_strategy"], + tokenizer=context["tokenizer"], + function_invocation_kwargs=context["function_invocation_kwargs"], + client_kwargs=context["client_kwargs"], ) - def _propagate_conversation_id( - update: AgentResponseUpdate, - ) -> AgentResponseUpdate: - """Eagerly propagate conversation_id to session as updates arrive. + return self.client.get_response( # type: ignore[call-overload, no-any-return] + messages=context["session_messages"], + stream=False, + options=context["chat_options"], # type: ignore[reportArgumentType] + compaction_strategy=context["compaction_strategy"], + tokenizer=context["tokenizer"], + function_invocation_kwargs=context["function_invocation_kwargs"], + client_kwargs=context["client_kwargs"], + ) - This ensures session.service_session_id is set even when the user - only iterates the stream without calling get_final_response(). - """ + async def _parse_non_streaming_response( + self, + context: _RunContext, + response: ChatResponse[Any], + ) -> AgentResponse[Any]: + """Finalize a non-streaming chat response into an AgentResponse.""" + if not response: + raise AgentInvalidResponseException("Chat client did not return a response.") + + await self._finalize_response( + response=response, + agent_name=context["agent_name"], + session=context["session"], + session_context=context["session_context"], + suppress_response_id=context["suppress_response_id"], + ) + return AgentResponse( + messages=response.messages, + response_id=None if context["suppress_response_id"] else response.response_id, + created_at=response.created_at, + usage_details=response.usage_details, + value=response.value, + response_format=context["chat_options"].get("response_format"), + continuation_token=response.continuation_token, + raw_representation=response, + additional_properties=response.additional_properties, + ) + + def _parse_streaming_response( + self, + context: _RunContext, + stream_response: ResponseStream[ChatResponseUpdate, ChatResponse[Any]], + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Finalize a streaming chat response into an agent response stream.""" + + async def _post_hook(response: AgentResponse) -> None: + # Update thread with conversation_id derived from streaming raw updates. + # Using response_id here can break function-call continuation for APIs + # where response IDs are not valid conversation handles. + conversation_id = self._extract_conversation_id_from_streaming_response(response) + + for message in response.messages: + if message.author_name is None: + message.author_name = context["agent_name"] + + session = context["session"] + if ( + session + and conversation_id + and not is_local_history_conversation_id(conversation_id) + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + + suppress_response_id = context["suppress_response_id"] + session_context = context["session_context"] + session_context._response = AgentResponse( # type: ignore[assignment] + messages=response.messages, + response_id=None if suppress_response_id else response.response_id, + ) + await self._run_after_providers(session=session, context=session_context) + + def _propagate_conversation_id(update: AgentResponseUpdate) -> AgentResponseUpdate: + """Eagerly propagate conversation_id to session as updates arrive.""" + session = context["session"] if session is None: return update raw = update.raw_representation - conv_id = getattr(raw, "conversation_id", None) if raw else None - if isinstance(conv_id, str) and conv_id and session.service_session_id != conv_id: - session.service_session_id = conv_id + conversation_id = getattr(raw, "conversation_id", None) if raw else None + if ( + isinstance(conversation_id, str) + and conversation_id + and not is_local_history_conversation_id(conversation_id) + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + return update + + def _suppress_response_id(update: AgentResponseUpdate) -> AgentResponseUpdate: + """Hide raw service response ids when local per-service-call persistence owns continuation.""" + update.response_id = None return update def _finalizer(updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: - ctx = ctx_holder["ctx"] - rf = ( - ctx.get("chat_options", {}).get("response_format") - if ctx - else (options.get("response_format") if options else None) # type: ignore[union-attr] + return self._finalize_response_updates( + updates, + response_format=context["chat_options"].get("response_format"), ) - return self._finalize_response_updates(updates, response_format=rf) - return ( - ResponseStream - .from_awaitable(_get_stream()) # type: ignore[reportUnknownMemberType] - .map( - transform=partial( - map_chat_to_agent_update, - agent_name=self.name, - ), - finalizer=_finalizer, - ) - .with_transform_hook(_propagate_conversation_id) - .with_result_hook(_post_hook) + stream = stream_response.map( + transform=partial( + map_chat_to_agent_update, + agent_name=self.name, + ), + finalizer=_finalizer, ) + if context["suppress_response_id"]: + stream = stream.with_transform_hook(_suppress_response_id) + + return stream.with_transform_hook(_propagate_conversation_id).with_result_hook(_post_hook) def _finalize_response_updates( self, @@ -1042,10 +1118,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] response_format: Any | None = None, ) -> AgentResponse[Any]: """Finalize response updates into a single AgentResponse.""" - output_format_type = response_format if isinstance(response_format, type) else None return AgentResponse.from_updates( # pyright: ignore[reportUnknownVariableType] updates, - output_format_type=output_format_type, + output_format_type=response_format, ) @staticmethod @@ -1111,6 +1186,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] if active_session is None and self.context_providers: active_session = AgentSession() + per_service_call_history_providers = self._resolve_per_service_call_history_providers( + session=active_session, + options=opts, + service_stores_history=bool(store_), + ) + session_context, chat_options = await self._prepare_session_and_messages( session=active_session, input_messages=input_messages, @@ -1158,9 +1239,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] ) additional_function_arguments = {**effective_function_invocation_kwargs, **existing_additional_args} + model = opts.pop("model", None) + # Build options dict from run() options merged with provided options run_opts: dict[str, Any] = { - "model_id": opts.pop("model_id", None), "conversation_id": active_session.service_session_id if active_session else opts.pop("conversation_id", None), @@ -1181,6 +1263,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "user": opts.pop("user", None), **opts, # Remaining options are provider-specific } + if model is not None: + run_opts["model"] = model # Remove None values and merge with chat_options run_opts = {k: v for k, v in run_opts.items() if v is not None} co = _merge_options(chat_options, run_opts) @@ -1191,6 +1275,43 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if active_session is not None: effective_client_kwargs["session"] = active_session + if per_service_call_history_providers and active_session is not None: + per_service_call_history_middleware = PerServiceCallHistoryPersistingMiddleware( + agent=self, + session=active_session, + providers=per_service_call_history_providers, + ) + existing_middleware = effective_client_kwargs.get("middleware") + if isinstance(existing_middleware, Sequence) and not isinstance(existing_middleware, (str, bytes)): + effective_client_kwargs["middleware"] = [per_service_call_history_middleware, *existing_middleware] + elif existing_middleware is not None: + effective_client_kwargs["middleware"] = [ + per_service_call_history_middleware, + cast(MiddlewareTypes, existing_middleware), + ] + else: + effective_client_kwargs["middleware"] = [per_service_call_history_middleware] + provider_middleware = session_context.get_middleware() + if provider_middleware: + middleware_list = categorize_middleware(provider_middleware) + provider_function_chat_middleware = [ + *middleware_list["function"], + *middleware_list["chat"], + ] + if provider_function_chat_middleware: + existing_middleware = effective_client_kwargs.get("middleware") + if isinstance(existing_middleware, Sequence) and not isinstance(existing_middleware, (str, bytes)): + effective_client_kwargs["middleware"] = [ + *existing_middleware, + *provider_function_chat_middleware, + ] + elif existing_middleware is not None: + effective_client_kwargs["middleware"] = [ + cast(MiddlewareTypes, existing_middleware), + *provider_function_chat_middleware, + ] + else: + effective_client_kwargs["middleware"] = provider_function_chat_middleware return { "session": active_session, @@ -1198,6 +1319,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "input_messages": input_messages, "session_messages": session_messages, "agent_name": agent_name, + "suppress_response_id": bool(per_service_call_history_providers), "chat_options": co, "compaction_strategy": compaction_strategy or self.compaction_strategy, "tokenizer": tokenizer or self.tokenizer, @@ -1211,6 +1333,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] agent_name: str, session: AgentSession | None, session_context: SessionContext, + suppress_response_id: bool = False, ) -> None: """Finalize response by setting author names and running after_run providers. @@ -1219,6 +1342,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] agent_name: The name of the agent to set as author. session: The conversation session. session_context: The invocation context. + suppress_response_id: When True, omit the raw service response ID from the public response. """ # Ensure that the author name is set for each message in the response. for message in response.messages: @@ -1228,13 +1352,18 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] # Propagate conversation_id back to session (e.g. thread ID from Assistants API). # For Responses-style APIs this can rotate every turn (response_id-based continuation), # so refresh when a newer value is returned. - if session and response.conversation_id and session.service_session_id != response.conversation_id: + if ( + session + and response.conversation_id + and not is_local_history_conversation_id(response.conversation_id) + and session.service_session_id != response.conversation_id + ): session.service_session_id = response.conversation_id # Set the response on the context for after_run providers session_context._response = AgentResponse( # type: ignore[assignment] messages=response.messages, - response_id=response.response_id, + response_id=None if suppress_response_id else response.response_id, ) # Run after_run providers (reverse order) @@ -1284,9 +1413,15 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options or {}, ) - # Run before_run providers (forward order, skip BaseHistoryProvider with load_messages=False) + per_service_call_history_required = self.require_per_service_call_history_persistence and bool( + self._get_history_providers() + ) + + # Run before_run providers (forward order, skip HistoryProvider when per-service-call persistence owns history) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if per_service_call_history_required and isinstance(provider, HistoryProvider): + continue + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") @@ -1551,8 +1686,9 @@ class Agent( description: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsCoT | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: MutableMapping[str, Any] | None = None, @@ -1568,6 +1704,7 @@ class Agent( default_options=default_options, context_providers=context_providers, middleware=middleware, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, compaction_strategy=compaction_strategy, tokenizer=tokenizer, additional_properties=additional_properties, diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 1865da7928..ddd765e654 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -231,8 +231,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): streaming and non-streaming responses. For full-featured clients with middleware, telemetry, and function invocation support, - use the public client classes (e.g., ``OpenAIChatClient``, ``OpenAIResponsesClient``) - which compose these layers correctly. + use public client classes such as ``OpenAIChatClient`` which compose these layers correctly. Examples: .. code-block:: python @@ -254,7 +253,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): else: # Non-streaming implementation return ChatResponse( - messages=[Message(role="assistant", text="Hello!")], response_id="custom-response" + messages=[Message(role="assistant", contents=["Hello!"])], + response_id="custom-response", ) @@ -262,9 +262,9 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): client = CustomChatClient() # Use the client to get responses - response = await client.get_response([Message(role="user", text="Hello, how are you?")]) + response = await client.get_response([Message(role="user", contents=["Hello, how are you?"])]) # Or stream responses - async for update in client.get_response([Message(role="user", text="Hello!")], stream=True): + async for update in client.get_response([Message(role="user", contents=["Hello!"])], stream=True): print(update) """ @@ -346,10 +346,9 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): response_format: Any | None = None, ) -> ChatResponse[Any]: """Finalize response updates into a single ChatResponse.""" - output_format_type = response_format if isinstance(response_format, type) else None return ChatResponse.from_updates( # pyright: ignore[reportUnknownVariableType] updates, - output_format_type=output_format_type, + output_format_type=response_format, ) def _build_response_stream( @@ -573,6 +572,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): default_options: OptionsCoT | Mapping[str, Any] | None = None, context_providers: Sequence[Any] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -592,11 +592,15 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): tools: The tools to use for the request. default_options: A TypedDict containing chat options. When using a typed client like ``OpenAIChatClient``, this enables IDE autocomplete for provider-specific options - including temperature, max_tokens, model_id, tool_choice, and more. + including temperature, max_tokens, model, tool_choice, and more. Note: response_format typing does not flow into run outputs when set via default_options, and dict literals are accepted without specialized option typing. context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. + require_per_service_call_history_persistence: Whether to require per-service-call + chat history persistence. When enabled, history providers are invoked around + each model call instead of once per ``run()`` when the service is not already + storing history. function_invocation_configuration: Optional function invocation configuration override. compaction_strategy: Optional agent-level compaction override. When omitted, client-level compaction defaults remain in effect for each call. @@ -613,7 +617,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): from agent_framework.openai import OpenAIChatClient # Create a client - client = OpenAIChatClient(model_id="gpt-4") + client = OpenAIChatClient(model="gpt-4") # Create an agent using the convenience method agent = client.as_agent( @@ -637,6 +641,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): "default_options": cast(Any, default_options), "context_providers": context_providers, "middleware": middleware, + "require_per_service_call_history_persistence": require_per_service_call_history_persistence, "compaction_strategy": compaction_strategy, "tokenizer": tokenizer, "additional_properties": dict(additional_properties) if additional_properties is not None else None, @@ -809,22 +814,21 @@ class SupportsFileSearchTool(Protocol): # region SupportsGetEmbeddings Protocol -# Contravariant TypeVars for the Protocol +# TypeVars for the Protocol EmbeddingInputContraT = TypeVar( "EmbeddingInputContraT", default="str", contravariant=True, ) -EmbeddingOptionsContraT = TypeVar( - "EmbeddingOptionsContraT", +EmbeddingProtocolOptionsT = TypeVar( + "EmbeddingProtocolOptionsT", bound=TypedDict, # type: ignore[valid-type] default="EmbeddingGenerationOptions", - contravariant=True, ) @runtime_checkable -class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingT, EmbeddingOptionsContraT]): +class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingT, EmbeddingProtocolOptionsT]): """Protocol for an embedding client that can generate embeddings. This protocol enables duck-typing for embedding generation. Any class that @@ -851,8 +855,8 @@ class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingT, Embeddin self, values: Sequence[EmbeddingInputContraT], *, - options: EmbeddingOptionsContraT | None = None, - ) -> Awaitable[GeneratedEmbeddings[EmbeddingT]]: + options: EmbeddingProtocolOptionsT | None = None, + ) -> Awaitable[GeneratedEmbeddings[EmbeddingT, EmbeddingProtocolOptionsT]]: """Generate embeddings for the given values. Args: diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index 8a15a6438c..dd76d1f0f4 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -15,7 +15,7 @@ from typing import ( runtime_checkable, ) -from ._sessions import BaseContextProvider +from ._sessions import ContextProvider from ._types import ChatResponse, Content, Message if TYPE_CHECKING: @@ -877,7 +877,7 @@ class ToolResultCompactionStrategy: insertion_index = starts.get(group_id, 0) summary_message = Message( role="assistant", - text=summary_text, + contents=[summary_text], message_id=summary_id, additional_properties={ GROUP_ANNOTATION_KEY: summary_annotation, @@ -1015,10 +1015,10 @@ class SummarizationStrategy: try: summary_response: ChatResponse[None] = await self.client.get_response( [ - Message(role="system", text=self.prompt), + Message(role="system", contents=[self.prompt]), Message( role="user", - text=_format_messages_for_summary(messages_to_summarize), + contents=[_format_messages_for_summary(messages_to_summarize)], ), ], stream=False, @@ -1044,7 +1044,7 @@ class SummarizationStrategy: summary_message = Message( role="assistant", - text=summary_text, + contents=[summary_text], message_id=summary_id, additional_properties={ GROUP_ANNOTATION_KEY: summary_annotation, @@ -1152,7 +1152,7 @@ async def apply_compaction( COMPACTION_STATE_KEY: Final[str] = "_compaction_messages" -class CompactionProvider(BaseContextProvider): +class CompactionProvider(ContextProvider): """Context provider that compacts messages before and after agent runs. This provider accepts two separate strategies: diff --git a/python/packages/core/agent_framework/_docstrings.py b/python/packages/core/agent_framework/_docstrings.py index 44dd7c50a3..93854b4627 100644 --- a/python/packages/core/agent_framework/_docstrings.py +++ b/python/packages/core/agent_framework/_docstrings.py @@ -3,12 +3,14 @@ from __future__ import annotations import inspect +import textwrap from collections.abc import Callable, Mapping from typing import Any _GOOGLE_SECTION_HEADERS = ( "Args:", "Keyword Args:", + "Attributes:", "Returns:", "Raises:", "Examples:", @@ -45,6 +47,29 @@ def _format_keyword_arg_lines(extra_keyword_args: Mapping[str, str]) -> list[str return formatted_lines +def insert_docstring_block(docstring: str | None, *, block: str) -> str | None: + """Insert a preformatted block before the first Google-style section.""" + cleaned_block = textwrap.dedent(block).strip() + if not cleaned_block: + return docstring + if not docstring: + return cleaned_block + + lines = inspect.cleandoc(docstring).splitlines() + block_lines = cleaned_block.splitlines() + insert_index = _find_next_section_index(lines, 0) + + insertion: list[str] = [] + if insert_index > 0 and lines[insert_index - 1] != "": + insertion.append("") + insertion.extend(block_lines) + if insert_index < len(lines) and insertion[-1] != "": + insertion.append("") + + lines[insert_index:insert_index] = insertion + return "\n".join(lines).rstrip() + + def build_layered_docstring( source: Callable[..., Any], *, diff --git a/python/packages/core/agent_framework/_evaluation.py b/python/packages/core/agent_framework/_evaluation.py index 92a694cc36..682903d448 100644 --- a/python/packages/core/agent_framework/_evaluation.py +++ b/python/packages/core/agent_framework/_evaluation.py @@ -53,6 +53,7 @@ from typing import ( runtime_checkable, ) +from ._feature_stage import ExperimentalFeature, experimental from ._tools import FunctionTool from ._types import AgentResponse, Message @@ -64,6 +65,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +@experimental(feature_id=ExperimentalFeature.EVALS) class EvalNotPassedError(Exception): """Raised when evaluation results contain failures.""" @@ -71,6 +73,7 @@ class EvalNotPassedError(Exception): # region Core types +@experimental(feature_id=ExperimentalFeature.EVALS) @runtime_checkable class ConversationSplitter(Protocol): """Strategy for splitting a conversation into (query, response) messages. @@ -96,12 +99,14 @@ class ConversationSplitter(Protocol): # Fallback: split at last user message return EvalItem._split_last_turn_static(conversation) + item.split_messages(split=split_before_memory) """ def __call__(self, conversation: list[Message]) -> tuple[list[Message], list[Message]]: ... +@experimental(feature_id=ExperimentalFeature.EVALS) class ConversationSplit(str, Enum): """Built-in conversation split strategies. @@ -130,6 +135,7 @@ class ConversationSplit(str, Enum): return _BUILT_IN_SPLITTERS[self](conversation) +@experimental(feature_id=ExperimentalFeature.EVALS) @dataclass class ExpectedToolCall: """A tool call that an agent is expected to make. @@ -172,6 +178,7 @@ _BUILT_IN_SPLITTERS: dict[ConversationSplit, Callable[[list[Message]], tuple[lis } +@experimental(feature_id=ExperimentalFeature.EVALS) class EvalItem: """A single item to be evaluated. @@ -294,6 +301,7 @@ class EvalItem: # region Score and result types +@experimental(feature_id=ExperimentalFeature.EVALS) @dataclass class EvalScoreResult: """Result from a single evaluator on a single item. @@ -311,6 +319,7 @@ class EvalScoreResult: sample: dict[str, Any] | None = None +@experimental(feature_id=ExperimentalFeature.EVALS) @dataclass class EvalItemResult: """Per-item result from an evaluation run. @@ -357,6 +366,7 @@ class EvalItemResult: return self.status == "fail" +@experimental(feature_id=ExperimentalFeature.EVALS) class EvalResults: """Results from an evaluation run by a single provider. @@ -468,10 +478,7 @@ class EvalResults: """ if not self.all_passed: errored = (self.result_counts or {}).get("errored", 0) - detail = msg or ( - f"Eval run {self.run_id} {self.status}: " - f"{self.passed} passed, {self.failed} failed." - ) + detail = msg or (f"Eval run {self.run_id} {self.status}: {self.passed} passed, {self.failed} failed.") if errored: detail += f" {errored} errored." if self.report_url: @@ -495,6 +502,7 @@ class EvalResults: # region Evaluator protocol +@experimental(feature_id=ExperimentalFeature.EVALS) @runtime_checkable class Evaluator(Protocol): """Protocol for evaluation providers. @@ -545,6 +553,7 @@ class Evaluator(Protocol): # region Converter +@experimental(feature_id=ExperimentalFeature.EVALS) class AgentEvalConverter: """Converts agent-framework types to evaluation format. @@ -848,6 +857,7 @@ def _extract_overall_query(workflow_result: WorkflowRunResult) -> str | None: # region Local evaluation checks +@experimental(feature_id=ExperimentalFeature.EVALS) @dataclass class CheckResult: """Result of a single check on a single evaluation item. @@ -872,6 +882,7 @@ an awaitable ``CheckResult``; they will be awaited automatically by """ +@experimental(feature_id=ExperimentalFeature.EVALS) def keyword_check(*keywords: str, case_sensitive: bool = False) -> EvalCheck: """Check that the response contains all specified keywords. @@ -899,6 +910,7 @@ def keyword_check(*keywords: str, case_sensitive: bool = False) -> EvalCheck: return _check +@experimental(feature_id=ExperimentalFeature.EVALS) def tool_called_check(*tool_names: str, mode: Literal["all", "any"] = "all") -> EvalCheck: """Check that specific tools were called during the conversation. @@ -981,6 +993,7 @@ def _extract_tool_calls(item: EvalItem) -> list[tuple[str, dict[str, Any] | None return calls +@experimental(feature_id=ExperimentalFeature.EVALS) def tool_calls_present(item: EvalItem) -> CheckResult: """Check that all expected tool calls were made (unordered, extras OK). @@ -1022,6 +1035,7 @@ def tool_calls_present(item: EvalItem) -> CheckResult: ) +@experimental(feature_id=ExperimentalFeature.EVALS) def tool_call_args_match(item: EvalItem) -> CheckResult: """Check that expected tool calls match on name and arguments. @@ -1188,8 +1202,7 @@ def _coerce_result(value: Any, check_name: str) -> CheckResult: score = float(d["score"]) except (TypeError, ValueError) as exc: raise TypeError( - f"Function evaluator '{check_name}' returned dict with non-numeric 'score' value:" - f" {d['score']!r}" + f"Function evaluator '{check_name}' returned dict with non-numeric 'score' value: {d['score']!r}" ) from exc # Honour an explicit 'passed' override; otherwise threshold-based. passed = bool(d["passed"]) if "passed" in d else score >= float(d.get("threshold", 0.5)) @@ -1223,6 +1236,7 @@ def evaluator(fn: Callable[..., Any], /) -> EvalCheck: ... def evaluator(*, name: str | None = None) -> Callable[[Callable[..., Any]], EvalCheck]: ... +@experimental(feature_id=ExperimentalFeature.EVALS) def evaluator( fn: Callable[..., Any] | None = None, *, @@ -1325,6 +1339,7 @@ async def _run_check(check_fn: EvalCheck, item: EvalItem) -> CheckResult: return result +@experimental(feature_id=ExperimentalFeature.EVALS) class LocalEvaluator: """Evaluation provider that runs checks locally without API calls. @@ -1434,6 +1449,7 @@ class LocalEvaluator: # region Public orchestration functions +@experimental(feature_id=ExperimentalFeature.EVALS) async def evaluate_agent( *, agent: SupportsAgentRun | None = None, @@ -1637,6 +1653,7 @@ async def evaluate_agent( return await _run_evaluators(evaluators, items, eval_name=name) +@experimental(feature_id=ExperimentalFeature.EVALS) async def evaluate_workflow( *, workflow: Workflow, diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py new file mode 100644 index 0000000000..6fb698768c --- /dev/null +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -0,0 +1,279 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio.coroutines +import functools +import inspect +import sys +import warnings +from collections.abc import Callable +from enum import Enum +from types import MethodType +from typing import Any, Literal, TypeVar, cast + +from ._docstrings import insert_docstring_block + +FeatureStageT = TypeVar("FeatureStageT", bound=Callable[..., Any]) + +FeatureStageName = Literal["experimental", "release_candidate"] + +# Optional feature-stage metadata for warnings and best-effort introspection. +_FEATURE_ID_ATTR = "__feature_id__" +_FEATURE_STAGE_ATTR = "__feature_stage__" +_WARNED_FEATURES: set[tuple[type[Warning], str]] = set() +_EXPERIMENTAL_DOCSTRING = """\ +.. warning:: Experimental + + This API is experimental and subject to change or removal + in future versions without notice. +""" +_RELEASE_CANDIDATE_DOCSTRING = """\ +.. note:: Release candidate + + This API is in release-candidate stage and may receive + minor refinements before it is considered generally available. +""" + + +class ExperimentalFeature(str, Enum): + """Current experimental feature IDs. + + This enum is a stage-scoped inventory, not a stable introspection surface. + Members may move or be removed as features advance. The `__feature_id__` + attribute is also optional stage metadata and may disappear when a feature + is released, so consumer code should use `getattr(...)` rather than relying + on enum membership or attribute presence over time. + """ + + EVALS = "EVALS" + SKILLS = "SKILLS" + + +class ReleaseCandidateFeature(str, Enum): + """Current release-candidate feature IDs. + + This enum is a stage-scoped inventory, not a stable introspection surface. + Members may move or be removed as features advance. The `__feature_id__` + attribute is also optional stage metadata and may disappear when a feature + is released, so consumer code should use `getattr(...)` rather than relying + on enum membership or attribute presence over time. + """ + + +class FeatureStageWarning(FutureWarning): + """Base warning category for staged APIs.""" + + +class ExperimentalWarning(FeatureStageWarning): + """Warning emitted when an experimental API is used.""" + + +def _normalize_feature_id(feature_id: str | Enum) -> str: + return str(feature_id.value if isinstance(feature_id, Enum) else feature_id) + + +def _get_object_name(obj: Any) -> str: + return str(getattr(obj, "__qualname__", getattr(obj, "__name__", type(obj).__name__))) + + +def _get_descriptor_callable(obj: Any) -> Callable[..., Any]: + return cast(Callable[..., Any], obj.__func__) + + +def _is_protocol_class(obj: Any) -> bool: + return isinstance(obj, type) and bool(getattr(obj, "_is_protocol", False)) + + +def _build_stage_warning_message(*, stage: FeatureStageName, feature_id: str, object_name: str) -> str: + if stage == "experimental": + return ( + f"[{feature_id}] {object_name} is experimental and may change or be removed in future versions " + "without notice." + ) + + return ( + f"[{feature_id}] {object_name} is in release-candidate stage and may receive minor refinements before it is " + "considered generally available." + ) + + +def _set_feature_stage_metadata(obj: Any, *, stage: FeatureStageName, feature_id: str) -> None: + setattr(obj, _FEATURE_STAGE_ATTR, stage) + setattr(obj, _FEATURE_ID_ATTR, feature_id) + + +def _warn_on_feature_use( + *, + stage: FeatureStageName, + feature_id: str, + object_name: str, + category: type[Warning], + stacklevel: int, +) -> None: + warning_key = (category, feature_id) + if warning_key in _WARNED_FEATURES: + return + + warnings.warn( + _build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name), + category=category, + stacklevel=stacklevel, + ) + _WARNED_FEATURES.add(warning_key) + + +def _add_runtime_warning( + obj: FeatureStageT, + *, + stage: FeatureStageName, + feature_id: str, + category: type[Warning], +) -> FeatureStageT: + object_name = _get_object_name(obj) + + if isinstance(obj, type): + experimental_class = cast(type[Any], obj) + original_new: Any = experimental_class.__new__ + + @functools.wraps(original_new) + def __new__(cls: type[Any], /, *args: Any, **kwargs: Any) -> Any: + if cls is experimental_class: + _warn_on_feature_use( + stage=stage, + feature_id=feature_id, + object_name=object_name, + category=category, + stacklevel=3, + ) + if original_new is not object.__new__: + return original_new(cls, *args, **kwargs) + if cls.__init__ is object.__init__ and (args or kwargs): + raise TypeError(f"{cls.__name__}() takes no arguments") + return original_new(cls) + + experimental_class.__new__ = staticmethod(__new__) # type: ignore[assignment] + + original_init_subclass: Any = experimental_class.__init_subclass__ + if isinstance(original_init_subclass, MethodType): + original_init_subclass_func = original_init_subclass.__func__ + + @functools.wraps(original_init_subclass_func) + def bound_init_subclass_wrapper(*args: Any, **kwargs: Any) -> Any: + _warn_on_feature_use( + stage=stage, + feature_id=feature_id, + object_name=object_name, + category=category, + stacklevel=3, + ) + return original_init_subclass_func(*args, **kwargs) + + experimental_class.__init_subclass__ = classmethod(bound_init_subclass_wrapper) # type: ignore[assignment] + else: + + @functools.wraps(original_init_subclass) + def init_subclass_wrapper(*args: Any, **kwargs: Any) -> Any: + _warn_on_feature_use( + stage=stage, + feature_id=feature_id, + object_name=object_name, + category=category, + stacklevel=3, + ) + return original_init_subclass(*args, **kwargs) + + experimental_class.__init_subclass__ = init_subclass_wrapper # type: ignore[assignment] + + return cast(FeatureStageT, experimental_class) + + @functools.wraps(obj) + def wrapper(*args: Any, **kwargs: Any) -> Any: + _warn_on_feature_use( + stage=stage, + feature_id=feature_id, + object_name=object_name, + category=category, + stacklevel=3, + ) + return obj(*args, **kwargs) + + if inspect.iscoroutinefunction(obj): + if sys.version_info >= (3, 12): + wrapper = inspect.markcoroutinefunction(wrapper) + else: + wrapper._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore[attr-defined] + + return cast(FeatureStageT, wrapper) + + +def _feature_stage( + *, + stage: FeatureStageName, + feature_id: str | Enum, + docstring_block: str, + warning_category: type[Warning] | None, +) -> Callable[[FeatureStageT], FeatureStageT]: + normalized_feature_id = _normalize_feature_id(feature_id) + + def decorator(obj: FeatureStageT) -> FeatureStageT: + descriptor_wrapper: Callable[[Any], Any] | None = None + target: Any = obj + + if isinstance(obj, staticmethod): + descriptor_wrapper = staticmethod + target = _get_descriptor_callable(obj) + elif isinstance(obj, classmethod): + descriptor_wrapper = classmethod + target = _get_descriptor_callable(obj) + + if not callable(target): + raise TypeError(f"{stage} decorator can only be applied to classes and callables, not {obj!r}.") + + is_protocol_class = _is_protocol_class(target) + decorated: Any = target + if warning_category is not None and not is_protocol_class: + decorated = _add_runtime_warning( + target, + stage=stage, + feature_id=normalized_feature_id, + category=warning_category, + ) + + updated_docstring = insert_docstring_block(decorated.__doc__, block=docstring_block) + if updated_docstring is not None: + decorated.__doc__ = updated_docstring + + # runtime_checkable Protocol classes treat added class attributes as protocol members + # on older Python versions, which breaks isinstance/issubclass checks. + if not is_protocol_class: + _set_feature_stage_metadata(decorated, stage=stage, feature_id=normalized_feature_id) + if descriptor_wrapper is not None: + return cast(FeatureStageT, descriptor_wrapper(decorated)) + + return cast(FeatureStageT, decorated) + + return decorator + + +def experimental(*, feature_id: ExperimentalFeature) -> Callable[[FeatureStageT], FeatureStageT]: + """Mark a class or callable as experimental.""" + return _feature_stage( + stage="experimental", + feature_id=feature_id, + docstring_block=_EXPERIMENTAL_DOCSTRING, + warning_category=ExperimentalWarning, + ) + + +def release_candidate( + *, + feature_id: ReleaseCandidateFeature, +) -> Callable[[FeatureStageT], FeatureStageT]: + """Mark a class or callable as release-candidate.""" + return _feature_stage( + stage="release_candidate", + feature_id=feature_id, + docstring_block=_RELEASE_CANDIDATE_DOCSTRING, + warning_category=None, + ) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 0dab38c820..9dfb29932f 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import base64 +import contextvars import json import logging import re @@ -38,6 +39,7 @@ if TYPE_CHECKING: from mcp.shared.session import RequestResponder from ._clients import SupportsChatGetResponse + from ._middleware import FunctionInvocationContext logger = logging.getLogger(__name__) @@ -59,6 +61,9 @@ class MCPSpecificApproval(TypedDict, total=False): _MCP_REMOTE_NAME_KEY = "_mcp_remote_name" _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name" +_mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar("_mcp_call_headers") +MCP_DEFAULT_TIMEOUT = 30 +MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5 # region: Helpers @@ -137,6 +142,22 @@ def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str, return meta +def streamable_http_client(*args: Any, **kwargs: Any) -> _AsyncGeneratorContextManager[Any, None]: + """Lazily import the MCP streamable HTTP transport.""" + try: + from mcp.client.streamable_http import streamable_http_client as _streamable_http_client + except ModuleNotFoundError as ex: + missing_name = ex.name or str(ex) + if missing_name == "mcp" or missing_name.startswith("mcp.") or "mcp" in missing_name: + raise ModuleNotFoundError("`MCPStreamableHTTPTool` requires `mcp`. Please install `mcp`.") from ex + raise ModuleNotFoundError( + f"`MCPStreamableHTTPTool` requires streamable HTTP transport support. " + f"The optional dependency `{missing_name}` is not installed. Please update your dependencies." + ) from ex + + return _streamable_http_client(*args, **kwargs) # type: ignore[return-value] + + # region: MCP Plugin @@ -798,7 +819,7 @@ class MCPTool: return types.CreateMessageResult( role="assistant", content=mcp_content, - model=response.model_id or "unknown", + model=response.model or "unknown", ) async def logging_callback(self, params: types.LoggingMessageNotificationParams) -> None: @@ -951,9 +972,20 @@ class MCPTool: input_schema = dict(tool.inputSchema or {}) if input_schema.get("type") == "object" and "properties" not in input_schema: input_schema["properties"] = {} + + async def _call_tool_with_runtime_kwargs( + ctx: FunctionInvocationContext, + *, + _remote_tool_name: str = tool.name, + **kwargs: Any, + ) -> str | list[Content]: + call_kwargs = dict(ctx.kwargs) + call_kwargs.update(kwargs) + return await self.call_tool(_remote_tool_name, **call_kwargs) + # Create FunctionTools out of each tool func: FunctionTool = FunctionTool( - func=partial(self.call_tool, tool.name), + func=_call_tool_with_runtime_kwargs, name=local_name, description=tool.description or "", approval_mode=approval_mode, @@ -1386,6 +1418,7 @@ class MCPStreamableHTTPTool(MCPTool): client: SupportsChatGetResponse | None = None, additional_properties: dict[str, Any] | None = None, http_client: AsyncClient | None = None, + header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None, **kwargs: Any, ) -> None: """Initialize the MCP streamable HTTP tool. @@ -1433,6 +1466,11 @@ class MCPStreamableHTTPTool(MCPTool): ``streamable_http_client`` API will create and manage a default client. To configure headers, timeouts, or other HTTP client settings, create and pass your own ``asyncClient`` instance. + header_provider: Optional callable that receives the runtime keyword arguments + (from ``FunctionInvocationContext.kwargs``) and returns a ``dict[str, str]`` + of HTTP headers to inject into every outbound request to the MCP server. + Use this to forward per-request context (e.g. authentication tokens set in + agent middleware) without creating a separate ``httpx.AsyncClient``. kwargs: Additional keyword arguments (accepted for backward compatibility but not used). """ super().__init__( @@ -1453,6 +1491,7 @@ class MCPStreamableHTTPTool(MCPTool): self.url = url self.terminate_on_close = terminate_on_close self._httpx_client: AsyncClient | None = http_client + self._header_provider = header_provider def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP streamable HTTP client. @@ -1460,18 +1499,59 @@ class MCPStreamableHTTPTool(MCPTool): Returns: An async context manager for the streamable HTTP client transport. """ - try: - from mcp.client.streamable_http import streamable_http_client - except ModuleNotFoundError as ex: - raise ModuleNotFoundError("`mcp` is required to use `MCPStreamableHTTPTool`. Please install `mcp`.") from ex + from httpx import AsyncClient, Request, Timeout + + http_client = self._httpx_client + if self._header_provider is not None: + if http_client is None: + http_client = AsyncClient( + follow_redirects=True, + timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT), + ) + self._httpx_client = http_client + + if not hasattr(self, "_inject_headers_hook"): + + async def _inject_headers(request: Request) -> None: # noqa: RUF029 + headers = _mcp_call_headers.get({}) + for key, value in headers.items(): + request.headers[key] = value + + self._inject_headers_hook = _inject_headers # type: ignore[attr-defined] + http_client.event_hooks["request"].append(self._inject_headers_hook) # type: ignore[attr-defined] - # Pass the http_client (which may be None) to streamable_http_client return streamable_http_client( url=self.url, - http_client=self._httpx_client, + http_client=http_client, terminate_on_close=self.terminate_on_close if self.terminate_on_close is not None else True, ) + async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: + """Call a tool, injecting headers from the header_provider if configured. + + When a ``header_provider`` was supplied at construction time, the runtime + *kwargs* (originating from ``FunctionInvocationContext.kwargs``) are passed + to the provider. The returned headers are attached to every HTTP request + made during this tool call via a ``contextvars.ContextVar``. + + Args: + tool_name: The name of the tool to call. + + Keyword Args: + kwargs: Arguments to pass to the tool. + + Returns: + A list of Content items representing the tool output. + """ + if self._header_provider is not None: + headers = self._header_provider(kwargs) + token = _mcp_call_headers.set(headers) + try: + return await super().call_tool(tool_name, **kwargs) + finally: + _mcp_call_headers.reset(token) + return await super().call_tool(tool_name, **kwargs) + class MCPWebsocketTool(MCPTool): """MCP tool for connecting to WebSocket-based MCP servers. diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 31950e0d7b..64467b470e 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -294,7 +294,7 @@ class ChatContext: async def process(self, context: ChatContext, call_next): print(f"Chat client: {context.chat_client.__class__.__name__}") print(f"Messages: {len(context.messages)}") - print(f"Model: {context.options.get('model_id')}") + print(f"Model: {context.options.get('model')}") # Store metadata context.metadata["input_tokens"] = self.count_tokens(context.messages) @@ -502,7 +502,7 @@ class ChatMiddleware(ABC): # Add system prompt to messages from agent_framework import Message - context.messages.insert(0, Message(role="system", text=self.system_prompt)) + context.messages.insert(0, Message(role="system", contents=[self.system_prompt])) # Continue execution await call_next() diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 20e873039d..ccd28e7f76 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -40,7 +40,7 @@ class SerializationProtocol(Protocol): # Message implements SerializationProtocol via SerializationMixin - user_msg = Message(role="user", text="What's the weather like today?") + user_msg = Message(role="user", contents=["What's the weather like today?"]) # Serialize to dictionary - automatic type identification and nested serialization msg_dict = user_msg.to_dict() @@ -425,13 +425,13 @@ class SerializationMixin: from openai import AsyncOpenAI - # OpenAI chat client requires an AsyncOpenAI client instance - # The client is marked as INJECTABLE = {"client"} in OpenAIBase + # OpenAI chat client requires an AsyncOpenAI client instance. + # The client dependency is excluded from serialization. # Serialized data contains only the model configuration client_data = { "type": "open_ai_chat_client", - "model_id": "gpt-4o-mini", + "model": "gpt-4o-mini", # client is excluded from serialization } diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index 84656824aa..55d1a10a18 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -4,8 +4,8 @@ This module provides the core types for the context provider pipeline: - SessionContext: Per-invocation state passed through providers -- BaseContextProvider: Base class for context providers (renamed to ContextProvider in PR2) -- BaseHistoryProvider: Base class for history storage providers (renamed to HistoryProvider in PR2) +- ContextProvider: Base class for context providers +- HistoryProvider: Base class for history storage providers - AgentSession: Lightweight session state container - InMemoryHistoryProvider: Built-in in-memory history provider """ @@ -15,19 +15,34 @@ from __future__ import annotations import copy import uuid from abc import abstractmethod -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, ClassVar, cast +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar, TypeGuard, cast -from ._types import AgentResponse, Message +from ._middleware import ChatContext, ChatMiddleware +from ._types import AgentResponse, ChatResponse, Message, ResponseStream +from .exceptions import ChatClientInvalidResponseException if TYPE_CHECKING: from ._agents import SupportsAgentRun + from ._middleware import MiddlewareTypes # Registry of known types for state deserialization _STATE_TYPE_REGISTRY: dict[str, type] = {} +def _is_middleware_sequence( + middleware: MiddlewareTypes | Sequence[MiddlewareTypes], +) -> TypeGuard[Sequence[MiddlewareTypes]]: + return isinstance(middleware, Sequence) and not isinstance(middleware, (str, bytes)) + + +def _is_single_middleware( + middleware: MiddlewareTypes | Sequence[MiddlewareTypes], +) -> TypeGuard[MiddlewareTypes]: + return not _is_middleware_sequence(middleware) + + def register_state_type(cls: type) -> None: """Register a type for automatic deserialization in session state. @@ -131,6 +146,8 @@ class SessionContext: Maintains insertion order (provider execution order). instructions: Additional instructions added by providers. tools: Additional tools added by providers. + middleware: Dict mapping source_id -> chat/function middleware added by that provider. + Maintains insertion order (provider execution order). response: After invocation, contains the full AgentResponse, should not be changed. options: Options passed to agent.run() - read-only, for reflection only. metadata: Shared metadata dictionary for cross-provider communication. @@ -145,6 +162,7 @@ class SessionContext: context_messages: dict[str, list[Message]] | None = None, instructions: list[str] | None = None, tools: list[Any] | None = None, + middleware: dict[str, list[MiddlewareTypes]] | None = None, options: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, ): @@ -157,6 +175,7 @@ class SessionContext: context_messages: Pre-populated context messages by source. instructions: Pre-populated instructions. tools: Pre-populated tools. + middleware: Pre-populated chat/function middleware by source. options: Options from agent.run() - read-only for providers. metadata: Shared metadata for cross-provider communication. """ @@ -166,6 +185,10 @@ class SessionContext: self.context_messages: dict[str, list[Message]] = context_messages or {} self.instructions: list[str] = instructions or [] self.tools: list[Any] = tools or [] + self.middleware: dict[str, list[MiddlewareTypes]] = {} + if middleware: + for source_id, provider_middleware in middleware.items(): + self.extend_middleware(source_id, provider_middleware) self._response: AgentResponse | None = None self.options: dict[str, Any] = options or {} self.metadata: dict[str, Any] = metadata or {} @@ -236,6 +259,40 @@ class SessionContext: additional_properties["context_source"] = source_id self.tools.extend(tools) + def extend_middleware( + self, + source_id: str, + middleware: MiddlewareTypes | Sequence[MiddlewareTypes], + ) -> None: + """Add middleware to be applied for this invocation. + + Args: + source_id: The provider source_id adding this middleware. + middleware: A single chat/function middleware object/callable or sequence of middleware. + """ + from ._middleware import categorize_middleware + from .exceptions import MiddlewareException + + if _is_middleware_sequence(middleware): + middleware_items = list(middleware) + elif _is_single_middleware(middleware): + middleware_items = [middleware] + else: + raise TypeError("middleware must be a middleware object or a sequence of middleware objects.") + middleware_list = categorize_middleware(middleware_items) + if middleware_list["agent"]: + raise MiddlewareException("Context providers may only add chat or function middleware.") + if source_id not in self.middleware: + self.middleware[source_id] = [] + self.middleware[source_id].extend(middleware_items) + + def get_middleware(self) -> list[MiddlewareTypes]: + """Get provider-added chat/function middleware in provider execution order.""" + result: list[MiddlewareTypes] = [] + for middleware_items in self.middleware.values(): + result.extend(middleware_items) + return result + def get_messages( self, *, @@ -272,17 +329,12 @@ class SessionContext: return result -class BaseContextProvider: - """Base class for context providers (hooks pattern). +class ContextProvider: + """Base class for context providers. Context providers participate in the context engineering pipeline, adding context before model invocation and processing responses after. - Note: - This class uses a temporary name prefixed with ``_`` to avoid collision - with the existing ``ContextProvider`` in ``_memory.py``. It will be - renamed to ``ContextProvider`` in PR2 when the old class is removed. - Attributes: source_id: Unique identifier for this provider instance (required). Used for message/tool attribution so other providers can filter. @@ -312,7 +364,7 @@ class BaseContextProvider: Args: agent: The agent running this invocation. session: The current session. - context: The invocation context - add messages/instructions/tools here. + context: The invocation context - add messages/instructions/tools/chat/function middleware here. state: The provider-scoped mutable state dict for this provider. Full cross-provider state remains available at ``session.state``. """ @@ -339,7 +391,7 @@ class BaseContextProvider: """ -class BaseHistoryProvider(BaseContextProvider): +class HistoryProvider(ContextProvider): """Base class for conversation history storage providers. A single class configurable for different use cases: @@ -347,10 +399,6 @@ class BaseHistoryProvider(BaseContextProvider): - Audit/logging storage (stores only, doesn't load) - Evaluation storage (stores only for later analysis) - Note: - This class uses a temporary name prefixed with ``_`` to avoid collision - with existing types. It will be renamed to ``HistoryProvider`` in PR2. - Subclasses only need to implement ``get_messages()`` and ``save_messages()``. The default ``before_run``/``after_run`` handle loading and storing based on configuration flags. Override them for custom behavior. @@ -467,6 +515,183 @@ class BaseHistoryProvider(BaseContextProvider): await self.save_messages(context.session_id, messages_to_store, state=state) +LOCAL_HISTORY_CONVERSATION_ID = "agent_framework_local_history_persistence" + + +def is_local_history_conversation_id(conversation_id: str | None) -> bool: + """Return whether a conversation id is the local history-persistence sentinel.""" + return conversation_id == LOCAL_HISTORY_CONVERSATION_ID + + +def _response_contains_follow_up_request(response: ChatResponse) -> bool: + """Return whether a response requires another model call in the current run.""" + return any( + item.type in {"function_call", "function_approval_request"} + for message in response.messages + for item in message.contents + ) + + +def _split_service_call_messages(messages: Sequence[Message]) -> tuple[list[Message], dict[str, list[Message]]]: + """Split service-call messages into input messages and attributed context messages.""" + input_messages: list[Message] = [] + context_messages: dict[str, list[Message]] = {} + for message in messages: + attribution = message.additional_properties.get("_attribution") + if isinstance(attribution, Mapping): + attribution_mapping = cast(Mapping[str, Any], attribution) + source_id = attribution_mapping.get("source_id") + if isinstance(source_id, str): + context_messages.setdefault(source_id, []).append(message) + continue + input_messages.append(message) + return input_messages, context_messages + + +class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware): + """Persist local chat history after each service call when history is framework-managed. + + This middleware runs around each model call when + ``require_per_service_call_history_persistence`` is enabled. It loads history providers + before the model call, persists them after the model call, and uses a local + sentinel conversation id so the function loop follows the existing + service-managed branch without forwarding that sentinel to the leaf client. + """ + + def __init__( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + providers: Sequence[HistoryProvider], + ) -> None: + """Initialize the middleware. + + Args: + agent: The agent that owns the history providers. + session: The active session for the current run. + providers: The history providers participating in per-service-call persistence. + """ + self._agent = agent + self._session = session + self._providers = list(providers) + + async def _prepare_service_call_context(self, messages: Sequence[Message]) -> SessionContext: + """Create a per-call SessionContext and load history providers into it.""" + input_messages, context_messages = _split_service_call_messages(messages) + service_call_context = SessionContext( + session_id=self._session.session_id, + service_session_id=None, + input_messages=list(input_messages), + ) + for source_id, source_messages in context_messages.items(): + service_call_context.extend_messages(source_id, source_messages) + for provider in self._providers: + if not provider.load_messages: + continue + await provider.before_run( + agent=self._agent, + session=self._session, + context=service_call_context, + state=self._session.state.setdefault(provider.source_id, {}), + ) + return service_call_context + + async def _persist_service_call_response( + self, + *, + service_call_context: SessionContext, + response: ChatResponse, + ) -> None: + """Persist a single model-call response through the configured history providers.""" + service_call_context._response = AgentResponse( # type: ignore[assignment] + messages=response.messages, + response_id=None, + ) + for provider in reversed(self._providers): + await provider.after_run( + agent=self._agent, + session=self._session, + context=service_call_context, + state=self._session.state.setdefault(provider.source_id, {}), + ) + + def _strip_local_conversation_id(self, context: ChatContext) -> None: + """Remove the local sentinel before the leaf chat client is invoked.""" + if is_local_history_conversation_id(cast(str | None, context.kwargs.get("conversation_id"))): + context.kwargs.pop("conversation_id", None) + + if context.options is None: + return + + mutable_options = dict(context.options) + if is_local_history_conversation_id(cast(str | None, mutable_options.get("conversation_id"))): + mutable_options.pop("conversation_id", None) + context.options = mutable_options + + async def _finalize_response( + self, + *, + service_call_context: SessionContext, + response: ChatResponse, + ) -> ChatResponse: + """Persist a model response and apply the local follow-up sentinel when needed.""" + if response.conversation_id is not None and not is_local_history_conversation_id(response.conversation_id): + raise ChatClientInvalidResponseException( + "require_per_service_call_history_persistence cannot be used " + "when the chat client returns a real conversation_id." + ) + + await self._persist_service_call_response( + service_call_context=service_call_context, + response=response, + ) + if _response_contains_follow_up_request(response): + response.mark_internal_conversation_id() + response.conversation_id = LOCAL_HISTORY_CONVERSATION_ID + return response + + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + """Load and persist history providers around a single model call. + + Args: + context: The chat invocation context for the current model call. + call_next: The next middleware or the leaf chat client. + + Raises: + ChatClientInvalidResponseException: If the leaf client returns a real + service-managed conversation id while local per-service-call persistence is enabled. + ValueError: If the downstream middleware contract returns the wrong + result type for streaming or non-streaming execution. + """ + service_call_context = await self._prepare_service_call_context(context.messages) + context.messages = service_call_context.get_messages(include_input=True) + self._strip_local_conversation_id(context) + + await call_next() + + if context.result is None: + return + + if context.stream: + if not isinstance(context.result, ResponseStream): + raise ValueError("Streaming chat middleware requires a ResponseStream result.") + context.result = context.result.with_result_hook( + lambda response: self._finalize_response( + service_call_context=service_call_context, + response=response, + ) + ) + return + + if isinstance(context.result, ResponseStream): + raise ValueError("Non-streaming chat middleware requires a ChatResponse result.") + context.result = await self._finalize_response( + service_call_context=service_call_context, + response=context.result, + ) + + class AgentSession: """A conversation session with an agent. @@ -535,7 +760,7 @@ class AgentSession: return session -class InMemoryHistoryProvider(BaseHistoryProvider): +class InMemoryHistoryProvider(HistoryProvider): """Built-in history provider that stores messages in session.state. Messages are stored in ``state["messages"]`` as a list of diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py index 4eecf3434d..9f9ab29a39 100644 --- a/python/packages/core/agent_framework/_settings.py +++ b/python/packages/core/agent_framework/_settings.py @@ -11,21 +11,21 @@ Usage:: class MySettings(TypedDict, total=False): api_key: str | None # optional — resolves to None if not set - model_id: str | None # optional by default + model: str | None # optional by default source_a: str | None source_b: str | None - # Make model_id required; require exactly one of source_a / source_b: + # Make model required; require exactly one of source_a / source_b: settings = load_settings( MySettings, env_prefix="MY_APP_", - required_fields=["model_id", ("source_a", "source_b")], - model_id="gpt-4", + required_fields=["model", ("source_a", "source_b")], + model="gpt-4", source_a="value", ) settings["api_key"] # type-checked dict access - settings["model_id"] # str | None per type, but guaranteed not None at runtime + settings["model"] # str | None per type, but guaranteed not None at runtime """ from __future__ import annotations diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index c95fc46aa2..5c99dbaa60 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -35,7 +35,8 @@ from html import escape as xml_escape from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, runtime_checkable -from ._sessions import BaseContextProvider +from ._feature_stage import ExperimentalFeature, experimental +from ._sessions import ContextProvider from ._tools import FunctionTool if TYPE_CHECKING: @@ -47,14 +48,10 @@ logger = logging.getLogger(__name__) # region Models +@experimental(feature_id=ExperimentalFeature.SKILLS) class SkillResource: """A named piece of supplementary content attached to a skill. - .. warning:: Experimental - - This API is experimental and subject to change or removal - in future versions without notice. - A resource provides data that an agent can retrieve on demand. It holds either a static ``content`` string or a ``function`` that produces content dynamically (sync or async). Exactly one must be provided. @@ -117,14 +114,10 @@ class SkillResource: self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) +@experimental(feature_id=ExperimentalFeature.SKILLS) class SkillScript: """An executable script attached to a skill. - .. warning:: Experimental - - This API is experimental and subject to change or removal - in future versions without notice. - A script represents executable code that an agent can run. It holds either an inline ``function`` callable (code-defined scripts) or a ``path`` to a script file on disk (file-based scripts). @@ -202,11 +195,6 @@ class SkillScript: def parameters_schema(self) -> dict[str, Any] | None: """JSON Schema describing the script's parameters. - .. warning:: Experimental - - This API is experimental and subject to change or removal - in future versions without notice. - Lazily generated from the callable's signature on first access. Returns ``None`` for file-based scripts or functions with no introspectable parameters. @@ -219,14 +207,10 @@ class SkillScript: return self._parameters_schema +@experimental(feature_id=ExperimentalFeature.SKILLS) class Skill: """A skill definition with optional resources. - .. warning:: Experimental - - This API is experimental and subject to change or removal - in future versions without notice. - A skill bundles a set of instructions (``content``) with metadata and zero or more :class:`SkillResource` and :class:`SkillScript` instances. Resources and scripts can be supplied at construction time or added later @@ -432,14 +416,10 @@ class Skill: @runtime_checkable +@experimental(feature_id=ExperimentalFeature.SKILLS) class SkillScriptRunner(Protocol): """Protocol for skill script runners. - .. warning:: Experimental - - This API is experimental and subject to change or removal - in future versions without notice. - A script runner determines how **file-based** skill scripts are run. Implementations decide the execution strategy (e.g., local subprocess, hosted code execution environment, @@ -538,14 +518,10 @@ SCRIPT_RUNNER_INSTRUCTIONS: Final[str] = ( # region SkillsProvider -class SkillsProvider(BaseContextProvider): +@experimental(feature_id=ExperimentalFeature.SKILLS) +class SkillsProvider(ContextProvider): """Context provider that advertises skills and exposes skill tools. - .. warning:: Experimental - - This API is experimental and subject to change or removal - in future versions without notice. - Supports both **file-based** skills (discovered from ``SKILL.md`` files) and **code-defined** skills (passed as :class:`Skill` instances). diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 521a0c4d96..6cdc74b313 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1688,6 +1688,34 @@ def _update_conversation_id( options["conversation_id"] = conversation_id +def _update_continuation_state( + kwargs: dict[str, Any], + response: ChatResponse[Any], + *, + session: AgentSession | None, + options: dict[str, Any] | None = None, +) -> None: + """Update in-flight and persisted continuation state from a response.""" + conversation_id = response.conversation_id + if conversation_id is None: + return + + _update_conversation_id(kwargs, conversation_id, options) + if ( + session is not None + and not response.has_internal_conversation_id() + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + + +def _clear_internal_conversation_id(response: ChatResponse[Any]) -> ChatResponse[Any]: + if response.has_internal_conversation_id(): + response.conversation_id = None + response.clear_internal_conversation_id() + return response + + def _extract_tools( options: dict[str, Any] | None, ) -> ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None: @@ -2206,9 +2234,14 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ), ) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) + _update_continuation_state( + filtered_kwargs, + response, + session=invocation_session, + options=mutable_options, + ) if response.conversation_id is not None: - _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( @@ -2223,7 +2256,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) if result.get("action") == "return": response.usage_details = aggregated_usage - return response + return _clear_internal_conversation_id(response) total_function_calls += result.get("function_call_count", 0) if result.get("action") == "stop": # Error threshold reached: force a final non-tool turn so @@ -2279,16 +2312,21 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ), ) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) + _update_continuation_state( + filtered_kwargs, + response, + session=invocation_session, + options=mutable_options, + ) response.usage_details = aggregated_usage if fcc_messages: for msg in reversed(fcc_messages): response.messages.insert(0, msg) - return response + return _clear_internal_conversation_id(response) return _get_response() response_format = mutable_options.get("response_format") if mutable_options else None - output_format_type: type[BaseModel] | None = response_format if isinstance(response_format, type) else None stream_result_hooks: list[Callable[[ChatResponse], Any]] = [] async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -2343,6 +2381,12 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): # Get the finalized response from the inner stream # This triggers the inner stream's finalizer and result hooks response = await inner_stream.get_final_response() + _update_continuation_state( + filtered_kwargs, + response, + session=invocation_session, + options=mutable_options, + ) if not any( item.type in ("function_call", "function_approval_request") @@ -2352,7 +2396,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): return if response.conversation_id is not None: - _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( @@ -2430,11 +2473,17 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): async for update in final_inner_stream: yield update # Finalize the inner stream to trigger its hooks - await final_inner_stream.get_final_response() + final_response = await final_inner_stream.get_final_response() + _update_continuation_state( + filtered_kwargs, + final_response, + session=invocation_session, + options=mutable_options, + ) def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]: # Note: stream_result_hooks are already run via inner stream's get_final_response() # We don't need to run them again here - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=response_format) return ResponseStream(_stream(), finalizer=_finalize) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 14c3050952..736474de60 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -299,6 +299,7 @@ ToolModeT = TypeVar("ToolModeT", bound="ToolMode") AgentResponseT = TypeVar("AgentResponseT", bound="AgentResponse") ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True) ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) +StructuredResponseFormat = type[BaseModel] | Mapping[str, Any] | None CreatedAtT = str # Use a datetimeoffset type? Or a more specific type like datetime.datetime? @@ -1380,6 +1381,13 @@ class Content: def _add_text_reasoning_content(self, other: Content) -> Content: """Add two TextReasoningContent instances.""" + # Ensure we do not silently merge contents with conflicting ids + if self.id and other.id and self.id != other.id: + raise AdditionItemMismatch( + f"Cannot add text_reasoning content with different ids: {self.id!r} != {other.id!r}" + ) + combined_id = self.id or other.id + # Concatenate text, handling None values self_text = self.text or "" # type: ignore[attr-defined] other_text = other.text or "" # type: ignore[attr-defined] @@ -1390,6 +1398,7 @@ class Content: return Content( "text_reasoning", + id=combined_id, text=combined_text, protected_data=protected_data, annotations=_combine_annotations(self.annotations, other.annotations), @@ -1660,7 +1669,6 @@ class Message(SerializationMixin): role: RoleLiteral | str, contents: Sequence[Content | str | Mapping[str, Any]] | None = None, *, - text: str | None = None, author_name: str | None = None, message_id: str | None = None, additional_properties: MutableMapping[str, Any] | None = None, @@ -1674,21 +1682,14 @@ class Message(SerializationMixin): to TextContent), or dicts (parsed via Content.from_dict). Defaults to empty list. Keyword Args: - text: Deprecated. Text content of the message. Use contents instead. - This parameter is kept for backward compatibility with serialization. author_name: Optional name of the author of the message. message_id: Optional ID of the chat message. additional_properties: Optional additional properties associated with the chat message. Additional properties are used within Agent Framework, they are not sent to services. raw_representation: Optional raw representation of the chat message. """ - # Handle contents conversion parsed_contents = [] if contents is None else _parse_content_list(contents) - # Handle text for backward compatibility (from serialization) - if text is not None: - parsed_contents.append(Content.from_text(text=text)) - self.role: str = role self.contents = parsed_contents self.author_name = author_name @@ -1787,7 +1788,19 @@ def prepend_instructions_to_messages( if isinstance(instructions, str): instructions = [instructions] - instruction_messages = [Message(role, [instr]) for instr in instructions] + # Skip instructions that are already present as leading messages with the + # same role and text. This prevents duplicate system messages when + # instructions are injected by multiple layers (e.g. Agent + chat client). + deduplicated: list[str] = [] + for idx, instr in enumerate(instructions): + if idx < len(messages) and messages[idx].role == role and messages[idx].text == instr: + continue + deduplicated.append(instr) + + if not deduplicated: + return messages + + instruction_messages = [Message(role, [instr]) for instr in deduplicated] return [*instruction_messages, *messages] @@ -1864,8 +1877,8 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse response.conversation_id = update.conversation_id if update.finish_reason is not None: response.finish_reason = update.finish_reason - if update.model_id is not None: - response.model_id = update.model_id + if update.model is not None: + response.model = update.model response.continuation_token = update.continuation_token @@ -1880,7 +1893,12 @@ def _coalesce_text_content(contents: list[Content], type_str: Literal["text", "t if first_new_content is None: first_new_content = deepcopy(content) else: - first_new_content += content + try: + first_new_content += content + except AdditionItemMismatch: + # Different IDs means a new logical segment; flush the current one + coalesced_contents.append(first_new_content) + first_new_content = deepcopy(content) else: # skip this content, it is not of the right type # so write the existing one to the list and start a new one, @@ -1936,6 +1954,24 @@ class ContinuationToken(TypedDict): # endregion +def _parse_structured_response_value(text: str, response_format: Any | None) -> Any | None: + if response_format is None: + return None + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return response_format.model_validate_json(text) + if isinstance(response_format, Mapping): + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Response text is not valid JSON: {exc}") from exc + logger.warning( + "Unable to parse structured response value, use either a Pydantic model or a dict defining the schema, " + "received response_format type: %s", + type(response_format), # type: ignore[reportUnknownArgumentType] + ) + return None + + class ChatResponse(SerializationMixin, Generic[ResponseModelT]): """Represents the response to a chat request. @@ -1943,7 +1979,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): messages: The list of chat messages in the response. response_id: The ID of the chat response. conversation_id: An identifier for the state of the conversation. - model_id: The model ID used in the creation of the chat response. + model: The model used in the creation of the chat response. created_at: A timestamp for the chat response. finish_reason: The reason for the chat response. usage_details: The usage details for the chat response. @@ -1966,7 +2002,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): response = ChatResponse( messages=[msg], finish_reason="stop", - model_id="gpt-4", + model="gpt-4", ) print(response.text) # "The weather is sunny." @@ -1976,18 +2012,19 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): # Serialization - to_dict and from_dict response_dict = response.to_dict() - # {'type': 'chat_response', 'messages': [...], 'model_id': 'gpt-4', 'finish_reason': 'stop'} + # {'type': 'chat_response', 'messages': [...], 'model': 'gpt-4', 'finish_reason': 'stop'} restored_response = ChatResponse.from_dict(response_dict) - print(restored_response.model_id) # "gpt-4" + print(restored_response.model) # "gpt-4" # Serialization - to_json and from_json response_json = response.to_json() - # '{"type": "chat_response", "messages": [...], "model_id": "gpt-4", ...}' + # '{"type": "chat_response", "messages": [...], "model": "gpt-4", ...}' restored_from_json = ChatResponse.from_json(response_json) print(restored_from_json.text) # "The weather is sunny." """ DEFAULT_EXCLUDE: ClassVar[set[str]] = {"raw_representation", "additional_properties"} + _INTERNAL_CONVERSATION_ID_KEY: ClassVar[str] = "_agent_framework_internal_conversation_id" def __init__( self, @@ -1996,12 +2033,11 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): response_id: str | None = None, conversation_id: str | None = None, model: str | None = None, - model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, usage_details: UsageDetails | None = None, value: ResponseModelT | None = None, - response_format: type[BaseModel] | None = None, + response_format: StructuredResponseFormat = None, continuation_token: ContinuationToken | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, @@ -2013,7 +2049,6 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): response_id: Optional ID of the chat response. conversation_id: Optional identifier for the state of the conversation. model: Optional model used in the creation of the chat response. - model_id: Deprecated alias for ``model``. created_at: Optional timestamp for when the response was created. finish_reason: Optional reason for the chat response (e.g., "stop", "length", "tool_calls"). usage_details: Optional usage details for the chat response. @@ -2024,8 +2059,6 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): additional_properties: Optional additional properties associated with the chat response. raw_representation: Optional raw representation of the chat response from an underlying implementation. """ - if model_id is not None and model is None: - model = model_id if messages is None: self.messages: list[Message] = [] elif isinstance(messages, Message): @@ -2048,7 +2081,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): self.finish_reason = finish_reason self.usage_details = usage_details self._value: ResponseModelT | None = value - self._response_format: type[BaseModel] | None = response_format + self._response_format: StructuredResponseFormat = response_format self._value_parsed: bool = value is not None self.additional_properties = ( _restore_compaction_annotation_in_additional_properties(additional_properties) or {} @@ -2056,14 +2089,17 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): self.continuation_token = continuation_token self.raw_representation: Any | list[Any] | None = raw_representation - @property - def model_id(self) -> str | None: - """Deprecated alias for :attr:`model`.""" - return self.model + def mark_internal_conversation_id(self) -> None: + """Mark the current conversation_id as internal control-flow state.""" + self.additional_properties[self._INTERNAL_CONVERSATION_ID_KEY] = True - @model_id.setter - def model_id(self, value: str | None) -> None: - self.model = value + def clear_internal_conversation_id(self) -> None: + """Remove the internal conversation-id marker.""" + self.additional_properties.pop(self._INTERNAL_CONVERSATION_ID_KEY, None) + + def has_internal_conversation_id(self) -> bool: + """Return whether conversation_id is internal control-flow state.""" + return bool(self.additional_properties.get(self._INTERNAL_CONVERSATION_ID_KEY, False)) @overload @classmethod @@ -2074,6 +2110,15 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): output_format_type: type[ResponseModelBoundT], ) -> ChatResponse[ResponseModelBoundT]: ... + @overload + @classmethod + def from_updates( + cls: type[ChatResponse[Any]], + updates: Sequence[ChatResponseUpdate], + *, + output_format_type: Mapping[str, Any], + ) -> ChatResponse[Any]: ... + @overload @classmethod def from_updates( @@ -2088,7 +2133,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[ChatResponseT], updates: Sequence[ChatResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. @@ -2111,10 +2156,10 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): updates: A sequence of ChatResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data. + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. """ - response_format = output_format_type if isinstance(output_format_type, type) else None - msg = cls(messages=[], response_format=response_format) + msg = cls(messages=[], response_format=output_format_type) for update in updates: _process_update(msg, update) _finalize_response(msg) @@ -2129,6 +2174,15 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): output_format_type: type[ResponseModelBoundT], ) -> ChatResponse[ResponseModelBoundT]: ... + @overload + @classmethod + async def from_update_generator( + cls: type[ChatResponse[Any]], + updates: AsyncIterable[ChatResponseUpdate], + *, + output_format_type: Mapping[str, Any], + ) -> ChatResponse[Any]: ... + @overload @classmethod async def from_update_generator( @@ -2143,7 +2197,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[ChatResponseT], updates: AsyncIterable[ChatResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. @@ -2162,10 +2216,10 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): updates: An async iterable of ChatResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data. + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. """ - response_format = output_format_type if isinstance(output_format_type, type) else None - msg = cls(messages=[], response_format=response_format) + msg = cls(messages=[], response_format=output_format_type) async for update in updates: _process_update(msg, update) _finalize_response(msg) @@ -2185,15 +2239,12 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): Raises: ValidationError: If the response text doesn't match the expected schema. + ValueError: If the response text is not valid JSON for a non-Pydantic structured format. """ if self._value_parsed: return self._value - if ( - self._response_format is not None - and isinstance(self._response_format, type) - and issubclass(self._response_format, BaseModel) - ): - self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) + if self._response_format is not None: + self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format)) self._value_parsed = True return self._value @@ -2217,7 +2268,7 @@ class ChatResponseUpdate(SerializationMixin): response_id: The ID of the response of which this update is a part. message_id: The ID of the message of which this update is a part. conversation_id: An identifier for the state of the conversation of which this update is a part. - model_id: The model ID associated with this response update. + model: The model associated with this response update. created_at: A timestamp for the chat response update. finish_reason: The finish reason for the operation. additional_properties: Any additional properties associated with the chat response update. @@ -2263,7 +2314,6 @@ class ChatResponseUpdate(SerializationMixin): message_id: str | None = None, conversation_id: str | None = None, model: str | None = None, - model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, continuation_token: ContinuationToken | None = None, @@ -2280,7 +2330,6 @@ class ChatResponseUpdate(SerializationMixin): message_id: Optional ID of the message of which this update is a part. conversation_id: Optional identifier for the state of the conversation of which this update is a part model: Optional model associated with this response update. - model_id: Deprecated alias for ``model``. created_at: Optional timestamp for the chat response update. finish_reason: Optional finish reason for the operation. continuation_token: Optional token for resuming a long-running background operation. @@ -2290,8 +2339,6 @@ class ChatResponseUpdate(SerializationMixin): from an underlying implementation. """ - if model_id is not None and model is None: - model = model_id # Handle contents - support dict conversion for from_dict if contents is None: self.contents: list[Content] = [] @@ -2321,15 +2368,6 @@ class ChatResponseUpdate(SerializationMixin): ) self.raw_representation = raw_representation - @property - def model_id(self) -> str | None: - """Deprecated alias for :attr:`model`.""" - return self.model - - @model_id.setter - def model_id(self, value: str | None) -> None: - self.model = value - @property def text(self) -> str: """Returns the concatenated text of all contents in the update.""" @@ -2397,7 +2435,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): created_at: CreatedAtT | None = None, usage_details: UsageDetails | None = None, value: ResponseModelT | None = None, - response_format: type[BaseModel] | None = None, + response_format: StructuredResponseFormat = None, continuation_token: ContinuationToken | None = None, raw_representation: Any | None = None, additional_properties: dict[str, Any] | None = None, @@ -2438,7 +2476,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): self.created_at = created_at self.usage_details = usage_details self._value: ResponseModelT | None = value - self._response_format: type[BaseModel] | None = response_format + self._response_format: type[BaseModel] | Mapping[str, Any] | None = response_format self._value_parsed: bool = value is not None self.additional_properties = ( _restore_compaction_annotation_in_additional_properties(additional_properties) or {} @@ -2460,15 +2498,12 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): Raises: ValidationError: If the response text doesn't match the expected schema. + ValueError: If the response text is not valid JSON for a non-Pydantic structured format. """ if self._value_parsed: return self._value - if ( - self._response_format is not None - and isinstance(self._response_format, type) - and issubclass(self._response_format, BaseModel) - ): - self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) + if self._response_format is not None: + self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format)) self._value_parsed = True return self._value @@ -2492,6 +2527,16 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): value: Any | None = None, ) -> AgentResponse[ResponseModelBoundT]: ... + @overload + @classmethod + def from_updates( + cls: type[AgentResponse[Any]], + updates: Sequence[AgentResponseUpdate], + *, + output_format_type: Mapping[str, Any], + value: Any | None = None, + ) -> AgentResponse[Any]: ... + @overload @classmethod def from_updates( @@ -2507,7 +2552,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[AgentResponseT], updates: Sequence[AgentResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, value: Any | None = None, ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. @@ -2516,7 +2561,8 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): updates: A sequence of AgentResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data. + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. value: Optional pre-parsed structured output value to set directly on the response. """ msg = cls(messages=[], response_format=output_format_type, value=value) @@ -2534,6 +2580,15 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): output_format_type: type[ResponseModelBoundT], ) -> AgentResponse[ResponseModelBoundT]: ... + @overload + @classmethod + async def from_update_generator( + cls: type[AgentResponse[Any]], + updates: AsyncIterable[AgentResponseUpdate], + *, + output_format_type: Mapping[str, Any], + ) -> AgentResponse[Any]: ... + @overload @classmethod async def from_update_generator( @@ -2548,7 +2603,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[AgentResponseT], updates: AsyncIterable[AgentResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. @@ -2556,7 +2611,8 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): updates: An async iterable of AgentResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. """ msg = cls(messages=[], response_format=output_format_type) async for update in updates: @@ -3095,12 +3151,12 @@ class _ChatOptionsBase(TypedDict, total=False): options: ChatOptions = { "temperature": 0.7, "max_tokens": 1000, - "model_id": "gpt-4", + "model": "gpt-4", } # With tools options_with_tools: ChatOptions = { - "model_id": "gpt-4", + "model": "gpt-4", "tool_choice": "auto", "temperature": 0.7, } @@ -3110,8 +3166,7 @@ class _ChatOptionsBase(TypedDict, total=False): """ # Model selection - model_id: str - + model: str # Generation parameters temperature: float top_p: float @@ -3347,10 +3402,10 @@ def merge_chat_options( from agent_framework import merge_chat_options - base = {"temperature": 0.5, "model_id": "gpt-4"} + base = {"temperature": 0.5, "model": "gpt-4"} override = {"temperature": 0.7, "max_tokens": 1000} merged = merge_chat_options(base, override) - # {"temperature": 0.7, "model_id": "gpt-4", "max_tokens": 1000} + # {"temperature": 0.7, "model": "gpt-4", "max_tokens": 1000} """ if not base: return dict(override) if override else {} @@ -3427,12 +3482,12 @@ class EmbeddingGenerationOptions(TypedDict, total=False): from agent_framework import EmbeddingGenerationOptions options: EmbeddingGenerationOptions = { - "model_id": "text-embedding-3-small", + "model": "text-embedding-3-small", "dimensions": 1536, } """ - model_id: str + model: str dimensions: int @@ -3466,13 +3521,10 @@ class Embedding(Generic[EmbeddingT]): vector: EmbeddingT, *, model: str | None = None, - model_id: str | None = None, dimensions: int | None = None, created_at: datetime | None = None, additional_properties: dict[str, Any] | None = None, ) -> None: - if model_id is not None and model is None: - model = model_id self.vector = vector self._dimensions = dimensions self.model = model @@ -3481,15 +3533,6 @@ class Embedding(Generic[EmbeddingT]): _restore_compaction_annotation_in_additional_properties(additional_properties) or {} ) - @property - def model_id(self) -> str | None: - """Deprecated alias for :attr:`model`.""" - return self.model - - @model_id.setter - def model_id(self, value: str | None) -> None: - self.model = value - @property def dimensions(self) -> int | None: """Return the number of dimensions in the embedding vector. diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index bf615814b3..60c5ec3774 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -6,7 +6,7 @@ import json import logging import sys import uuid -from collections.abc import AsyncIterable, Awaitable, Sequence +from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload @@ -14,8 +14,8 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload from .._agents import BaseAgent from .._sessions import ( AgentSession, - BaseContextProvider, - BaseHistoryProvider, + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, SessionContext, ) @@ -86,7 +86,7 @@ class WorkflowAgent(BaseAgent): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, **kwargs: Any, ) -> None: """Initialize the WorkflowAgent. @@ -152,7 +152,8 @@ class WorkflowAgent(BaseAgent): session: AgentSession | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... @overload @@ -164,7 +165,8 @@ class WorkflowAgent(BaseAgent): session: AgentSession | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AgentResponse: ... def run( @@ -175,7 +177,8 @@ class WorkflowAgent(BaseAgent): session: AgentSession | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> ResponseStream[AgentResponseUpdate, AgentResponse] | Awaitable[AgentResponse]: """Get a response from the workflow agent. @@ -192,8 +195,12 @@ class WorkflowAgent(BaseAgent): checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id, used to load and restore the checkpoint. When provided without checkpoint_id, enables checkpointing for this run. - **kwargs: Additional keyword arguments passed through to underlying workflow - and tool functions. + function_invocation_kwargs: Keyword arguments forwarded to tool invocations in + subagents. Either a mapping of agent name/executor id to kwargs, or a flat + mapping of kwargs for all tool invocations. + client_kwargs: Keyword arguments forwarded to chat client calls in + subagents. Either a mapping of agent name/executor id to kwargs, or a flat + mapping of kwargs for all chat client calls. Returns: When stream=True: An AsyncIterable[AgentResponseUpdate] for streaming updates. @@ -208,10 +215,26 @@ class WorkflowAgent(BaseAgent): response_id = str(uuid.uuid4()) if stream: return ResponseStream( - self._run_stream_impl(messages, response_id, session, checkpoint_id, checkpoint_storage, **kwargs), + self._run_stream_impl( + messages, + response_id, + session, + checkpoint_id, + checkpoint_storage, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + ), finalizer=AgentResponse.from_updates, ) - return self._run_impl(messages, response_id, session, checkpoint_id, checkpoint_storage, **kwargs) + return self._run_impl( + messages, + response_id, + session, + checkpoint_id, + checkpoint_storage, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + ) async def _run_impl( self, @@ -220,7 +243,8 @@ class WorkflowAgent(BaseAgent): session: AgentSession | None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AgentResponse: """Internal implementation of non-streaming execution. @@ -230,8 +254,8 @@ class WorkflowAgent(BaseAgent): session: The agent session for conversation context. checkpoint_id: ID of checkpoint to restore from. checkpoint_storage: Runtime checkpoint storage. - **kwargs: Additional keyword arguments passed through to the underlying - workflow and tool functions. + function_invocation_kwargs: Optional kwargs for tool invocations. + client_kwargs: Optional kwargs for chat client calls. Returns: An AgentResponse representing the workflow execution results. @@ -249,7 +273,7 @@ class WorkflowAgent(BaseAgent): options={}, ) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") @@ -264,7 +288,12 @@ class WorkflowAgent(BaseAgent): output_events: list[WorkflowEvent[Any]] = [] async for event in self._run_core( - session_messages, checkpoint_id, checkpoint_storage, streaming=False, **kwargs + session_messages, + checkpoint_id, + checkpoint_storage, + streaming=False, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ): if event.type == "output" or event.type == "request_info": output_events.append(event) @@ -285,7 +314,8 @@ class WorkflowAgent(BaseAgent): session: AgentSession | None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[AgentResponseUpdate]: """Internal implementation of streaming execution. @@ -295,8 +325,8 @@ class WorkflowAgent(BaseAgent): session: The agent session for conversation context. checkpoint_id: ID of checkpoint to restore from. checkpoint_storage: Runtime checkpoint storage. - **kwargs: Additional keyword arguments passed through to the underlying - workflow and tool functions. + function_invocation_kwargs: Optional kwargs for tool invocations. + client_kwargs: Optional kwargs for chat client calls. Yields: AgentResponseUpdate objects representing the workflow execution progress. @@ -314,7 +344,7 @@ class WorkflowAgent(BaseAgent): options={}, ) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") @@ -329,7 +359,12 @@ class WorkflowAgent(BaseAgent): session_messages: list[Message] = session_context.get_messages(include_input=True) all_updates: list[AgentResponseUpdate] = [] async for event in self._run_core( - session_messages, checkpoint_id, checkpoint_storage, streaming=True, **kwargs + session_messages, + checkpoint_id, + checkpoint_storage, + streaming=True, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ): updates = self._convert_workflow_event_to_agent_response_updates(response_id, event) for update in updates: @@ -349,7 +384,8 @@ class WorkflowAgent(BaseAgent): checkpoint_id: str | None, checkpoint_storage: CheckpointStorage | None, streaming: bool, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[WorkflowEvent]: """Core implementation that yields workflow events for both streaming and non-streaming modes. @@ -358,8 +394,8 @@ class WorkflowAgent(BaseAgent): checkpoint_id: ID of checkpoint to restore from. checkpoint_storage: Runtime checkpoint storage. streaming: Whether to use streaming workflow methods. - **kwargs: Additional keyword arguments passed through to the underlying - workflow and tool functions. + function_invocation_kwargs: Optional kwargs for tool invocations. + client_kwargs: Optional kwargs for chat client calls. Yields: WorkflowEvent objects from the workflow execution. @@ -371,10 +407,19 @@ class WorkflowAgent(BaseAgent): if bool(self.pending_requests): function_responses = self._process_pending_requests(input_messages) if streaming: - async for event in self.workflow.run(responses=function_responses, stream=True, **kwargs): + async for event in self.workflow.run( + responses=function_responses, + stream=True, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + ): yield event else: - for event in await self.workflow.run(responses=function_responses, **kwargs): + for event in await self.workflow.run( + responses=function_responses, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + ): yield event elif checkpoint_id is not None: @@ -383,14 +428,16 @@ class WorkflowAgent(BaseAgent): stream=True, checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage, - **kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ): yield event else: for event in await self.workflow.run( checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage, - **kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ): yield event @@ -400,14 +447,16 @@ class WorkflowAgent(BaseAgent): message=input_messages, stream=True, checkpoint_storage=checkpoint_storage, - **kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ): yield event else: for event in await self.workflow.run( message=input_messages, checkpoint_storage=checkpoint_storage, - **kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ): yield event diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 02b4da943c..986b086fab 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -2,7 +2,7 @@ import logging import sys -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, Literal, cast @@ -14,7 +14,7 @@ from .._agents import SupportsAgentRun from .._sessions import AgentSession from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream from ._agent_utils import resolve_agent_id -from ._const import WORKFLOW_RUN_KWARGS_KEY +from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY from ._executor import Executor, handler from ._message_utils import normalize_messages_input from ._request_info_mixin import response_handler @@ -251,21 +251,6 @@ class AgentExecutor(Executor): Returns: Dict containing serialized cache and session state """ - # Check if using AzureAIAgentClient with server-side session and warn about checkpointing limitations - if is_chat_agent(self._agent) and self._session.service_session_id is not None: - client_class_name = self._agent.client.__class__.__name__ - client_module = self._agent.client.__class__.__module__ - - if client_class_name == "AzureAIAgentClient" and "azure_ai" in client_module: - logger.warning( - "Checkpointing an AgentExecutor with AzureAIAgentClient that uses server-side sessions. " - "Currently, checkpointing does not capture messages from server-side sessions " - "(service_session_id: %s). The session state in checkpoints is not immutable and can be " - "modified by subsequent runs. If you need reliable checkpointing with Azure AI agents, " - "consider implementing a custom executor and managing the session state yourself.", - self._session.service_session_id, - ) - serialized_session = self._session.to_dict() return { @@ -350,15 +335,17 @@ class AgentExecutor(Executor): Returns: The complete AgentResponse, or None if waiting for user input. """ - run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})) + function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args( + ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) + ) run_agent = cast(Callable[..., Awaitable[AgentResponse[Any]]], self._agent.run) response = await run_agent( self._cache, stream=False, session=self._session, - options=options, - **run_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) await ctx.yield_output(response) @@ -380,7 +367,9 @@ class AgentExecutor(Executor): Returns: The complete AgentResponse, or None if waiting for user input. """ - run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})) + function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args( + ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) + ) updates: list[AgentResponseUpdate] = [] streamed_user_input_requests: list[Content] = [] @@ -389,8 +378,8 @@ class AgentExecutor(Executor): self._cache, stream=True, session=self._session, - options=options, - **run_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) async for update in stream: updates.append(update) @@ -436,74 +425,58 @@ class AgentExecutor(Executor): return response - # Parameters that are explicitly passed to agent.run() by AgentExecutor - # and must not appear in **run_kwargs to avoid TypeError from duplicate values. - _RESERVED_RUN_PARAMS: frozenset[str] = frozenset({"session", "stream", "messages"}) + def _prepare_agent_run_args( + self, + raw_run_kwargs: dict[str, Any], + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Prepare function_invocation_kwargs and client_kwargs for agent.run(). - @staticmethod - def _prepare_agent_run_args(raw_run_kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: - """Prepare kwargs and options for agent.run(), avoiding duplicate option passing. + Extracts ``function_invocation_kwargs`` and ``client_kwargs`` from the + workflow state dict, resolving per-executor entries using ``self.id``. The + ``__global__`` sentinel key (set by ``Workflow._resolve_invocation_kwargs``) denotes + global kwargs that apply to all executors. Per-executor dicts use executor IDs as + keys; this executor extracts only its own entry. - Workflow-level kwargs are propagated to tool calls through - `options.additional_function_arguments`. If workflow kwargs include an - `options` key, merge it into the final options object and remove it from - kwargs before spreading `**run_kwargs`. - - Reserved parameters (session, stream, messages) that are explicitly - managed by AgentExecutor are stripped from run_kwargs to prevent - ``TypeError: got multiple values for keyword argument`` collisions. + Returns: + A 2-tuple of (function_invocation_kwargs, client_kwargs). """ - run_kwargs = dict(raw_run_kwargs) + fi_resolved = raw_run_kwargs.get("function_invocation_kwargs") + ci_resolved = raw_run_kwargs.get("client_kwargs") - # Strip reserved params that AgentExecutor passes explicitly to agent.run(). - for key in AgentExecutor._RESERVED_RUN_PARAMS: - if key in run_kwargs: - logger.warning( - "Workflow kwarg '%s' is reserved by AgentExecutor and will be ignored. " - "Remove it from workflow.run() kwargs to silence this warning.", - key, - ) - run_kwargs.pop(key) + function_invocation_kwargs = self._resolve_executor_kwargs(fi_resolved) + client_kwargs = self._resolve_executor_kwargs(ci_resolved) - options_from_workflow = run_kwargs.pop("options", None) - workflow_additional_args = run_kwargs.pop("additional_function_arguments", None) + return function_invocation_kwargs, client_kwargs - options: dict[str, Any] = {} - if options_from_workflow is not None: - if isinstance(options_from_workflow, Mapping): - options_from_workflow_map = cast(Mapping[str, Any], options_from_workflow) - for key, value in options_from_workflow_map.items(): - options[key] = value - else: - logger.warning( - "Ignoring non-mapping workflow 'options' kwarg of type %s for AgentExecutor %s.", - type(options_from_workflow).__name__, - AgentExecutor.__name__, - ) + def _resolve_executor_kwargs(self, resolved: dict[str, Any] | None) -> dict[str, Any] | None: + """Extract this executor's kwargs from a resolved invocation kwargs dict. - existing_additional_args = options.get("additional_function_arguments") - additional_args: dict[str, Any] - if isinstance(existing_additional_args, Mapping): - existing_additional_args_map = cast(Mapping[str, Any], existing_additional_args) - additional_args = {key: value for key, value in existing_additional_args_map.items()} + Args: + resolved: The resolved dict produced by ``Workflow._resolve_invocation_kwargs``, + containing either a ``__global__`` key (global kwargs) or executor-ID keys + (per-executor kwargs). May also be ``None``. + + Returns: + The kwargs for this executor, or ``None`` if not applicable. + """ + if not isinstance(resolved, dict): + return None + # Use explicit key-presence checks so that an empty per-executor dict is + # honoured (e.g. to clear kwargs) instead of falling through to global. + if self.id in resolved: + executor_kwargs = resolved[self.id] + elif GLOBAL_KWARGS_KEY in resolved: + executor_kwargs = resolved[GLOBAL_KWARGS_KEY] else: - additional_args = {} + return None - if workflow_additional_args is not None: - if isinstance(workflow_additional_args, Mapping): - workflow_additional_args_map = cast(Mapping[str, Any], workflow_additional_args) - additional_args.update({key: value for key, value in workflow_additional_args_map.items()}) - else: - logger.warning( - "Ignoring non-mapping workflow 'additional_function_arguments' kwarg of type %s for AgentExecutor %s.", # noqa: E501 - type(workflow_additional_args).__name__, - AgentExecutor.__name__, - ) + if not isinstance(executor_kwargs, dict): + logger.warning( + "Executor %s expected a dict for its kwargs, but got %s. Ignoring.", + self.id, + type(executor_kwargs), # type: ignore + ) - if run_kwargs: - additional_args.update(run_kwargs) + return None - if additional_args: - options["additional_function_arguments"] = additional_args - - return run_kwargs, options or None + return executor_kwargs # type: ignore diff --git a/python/packages/core/agent_framework/_workflows/_const.py b/python/packages/core/agent_framework/_workflows/_const.py index a8416af790..e83025bbdc 100644 --- a/python/packages/core/agent_framework/_workflows/_const.py +++ b/python/packages/core/agent_framework/_workflows/_const.py @@ -14,6 +14,10 @@ INTERNAL_SOURCE_PREFIX = "internal" # to pass kwargs from workflow.run() through to agent.run() and @tool functions. WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs" +# Sentinel key used in resolved invocation kwargs dicts to denote global kwargs +# that apply to all executors (as opposed to per-executor keyed entries). +GLOBAL_KWARGS_KEY = "__global__" + def INTERNAL_SOURCE_ID(executor_id: str) -> str: """Generate an internal source ID for a given executor.""" diff --git a/python/packages/core/agent_framework/_workflows/_message_utils.py b/python/packages/core/agent_framework/_workflows/_message_utils.py index ba69fa340d..011c52af43 100644 --- a/python/packages/core/agent_framework/_workflows/_message_utils.py +++ b/python/packages/core/agent_framework/_workflows/_message_utils.py @@ -21,7 +21,7 @@ def normalize_messages_input( return [] if isinstance(messages, str): - return [Message(role="user", text=messages)] + return [Message(role="user", contents=[messages])] if isinstance(messages, Content): return [Message(role="user", contents=[messages])] @@ -31,9 +31,7 @@ def normalize_messages_input( normalized: list[Message] = [] for item in messages: - if isinstance(item, str): - normalized.append(Message(role="user", text=item)) - elif isinstance(item, Content): + if isinstance(item, (str, Content)): normalized.append(Message(role="user", contents=[item])) elif isinstance(item, Message): normalized.append(item) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index cf030bf7b0..58050eece9 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -10,14 +10,14 @@ import json import logging import types import uuid -from collections.abc import AsyncIterable, Awaitable, Callable, Sequence +from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence from typing import Any, Literal, overload from .._types import ResponseStream from ..observability import OtelAttr, capture_exception, create_workflow_span from ._agent import WorkflowAgent from ._checkpoint import CheckpointStorage -from ._const import DEFAULT_MAX_ITERATIONS, WORKFLOW_RUN_KWARGS_KEY +from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY from ._edge import ( EdgeGroup, FanOutEdgeGroup, @@ -180,7 +180,6 @@ class Workflow(DictConvertible): description: str | None = None, max_iterations: int = DEFAULT_MAX_ITERATIONS, output_executors: list[str] | None = None, - **kwargs: Any, ): """Initialize the workflow with a list of edges. @@ -198,7 +197,6 @@ class Workflow(DictConvertible): WorkflowBuilder, this will be the description of the builder. output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs. If None or empty, all executor outputs are treated as workflow outputs. - kwargs: Additional keyword arguments. Unused in this implementation. """ self.edge_groups = list(edge_groups) self.executors = dict(executors) @@ -300,7 +298,8 @@ class Workflow(DictConvertible): initial_executor_fn: Callable[[], Awaitable[None]] | None = None, reset_context: bool = True, streaming: bool = False, - run_kwargs: dict[str, Any] | None = None, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[WorkflowEvent]: """Private method to run workflow with proper tracing. @@ -311,7 +310,10 @@ class Workflow(DictConvertible): initial_executor_fn: Optional function to execute initial executor reset_context: Whether to reset the context for a new run streaming: Whether to enable streaming mode for agents - run_kwargs: Optional kwargs to store in State for agent invocations + function_invocation_kwargs: Optional kwargs to store in State for function + invocations in subagents + client_kwargs: Optional kwargs to store in State for chat client + invocations in subagents Yields: WorkflowEvent: The events generated during the workflow execution. @@ -350,8 +352,17 @@ class Workflow(DictConvertible): # Only overwrite when new kwargs are explicitly provided or state was # just cleared (fresh run). On continuation (reset_context=False) with # no new kwargs, preserve the kwargs from the original run. - if run_kwargs is not None: - self._state.set(WORKFLOW_RUN_KWARGS_KEY, run_kwargs) + if function_invocation_kwargs is not None or client_kwargs is not None: + combined_kwargs: dict[str, Any] = {} + if function_invocation_kwargs is not None: + combined_kwargs["function_invocation_kwargs"] = self._resolve_invocation_kwargs( + function_invocation_kwargs, "function_invocation_kwargs" + ) + if client_kwargs is not None: + combined_kwargs["client_kwargs"] = self._resolve_invocation_kwargs( + client_kwargs, "client_kwargs" + ) + self._state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs) elif reset_context: self._state.set(WORKFLOW_RUN_KWARGS_KEY, {}) self._state.commit() # Commit immediately so kwargs are available @@ -459,10 +470,11 @@ class Workflow(DictConvertible): message: Any | None = None, *, stream: Literal[True], - responses: dict[str, Any] | None = None, + responses: Mapping[str, Any] | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> ResponseStream[WorkflowEvent, WorkflowRunResult]: ... @overload @@ -471,11 +483,12 @@ class Workflow(DictConvertible): message: Any | None = None, *, stream: Literal[False] = ..., - responses: dict[str, Any] | None = None, + responses: Mapping[str, Any] | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, include_status_events: bool = False, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[WorkflowRunResult]: ... def run( @@ -483,11 +496,12 @@ class Workflow(DictConvertible): message: Any | None = None, *, stream: bool = False, - responses: dict[str, Any] | None = None, + responses: Mapping[str, Any] | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, include_status_events: bool = False, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> ResponseStream[WorkflowEvent, WorkflowRunResult] | Awaitable[WorkflowRunResult]: """Run the workflow, optionally streaming events. @@ -509,7 +523,12 @@ class Workflow(DictConvertible): (restore then send responses). checkpoint_storage: Runtime checkpoint storage. include_status_events: Whether to include status events (non-streaming only). - **kwargs: Additional keyword arguments to pass through to agent invocations. + function_invocation_kwargs: Keyword arguments forwarded to tool invocations in + subagents. Either a mapping for agent name or agent executor id to kwargs, + or a flat mapping of kwargs for all tool invocations. + client_kwargs: Keyword arguments forwarded to chat client calls in + subagents. Either a mapping for agent name or agent executor id to kwargs, + or a flat mapping of kwargs for all chat client calls. Returns: When stream=True: A ResponseStream[WorkflowEvent, WorkflowRunResult] for @@ -530,7 +549,8 @@ class Workflow(DictConvertible): checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage, streaming=stream, - **kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ), finalizer=functools.partial(self._finalize_events, include_status_events=include_status_events), cleanup_hooks=[ @@ -546,11 +566,12 @@ class Workflow(DictConvertible): self, message: Any | None = None, *, - responses: dict[str, Any] | None = None, + responses: Mapping[str, Any] | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, streaming: bool = False, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[WorkflowEvent]: """Single core execution path for both streaming and non-streaming modes. @@ -569,11 +590,8 @@ class Workflow(DictConvertible): initial_executor_fn=initial_executor_fn, reset_context=reset_context, streaming=streaming, - # Empty **kwargs (no caller-provided kwargs) is collapsed to None so that - # continuation calls without explicit kwargs preserve the original run's kwargs. - # A non-empty kwargs dict (even one with empty values like {"key": {}}) - # is passed through and will overwrite stored kwargs. - run_kwargs=kwargs if kwargs else None, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ): if event.type == "output" and not self._should_yield_output_event(event): continue @@ -624,7 +642,7 @@ class Workflow(DictConvertible): @staticmethod def _validate_run_params( message: Any | None, - responses: dict[str, Any] | None, + responses: Mapping[str, Any] | None, checkpoint_id: str | None, ) -> None: """Validate parameter combinations for run(). @@ -650,7 +668,7 @@ class Workflow(DictConvertible): def _resolve_execution_mode( self, message: Any | None, - responses: dict[str, Any] | None, + responses: Mapping[str, Any] | None, checkpoint_id: str | None, checkpoint_storage: CheckpointStorage | None, ) -> tuple[Callable[[], Awaitable[None]], bool]: @@ -680,7 +698,7 @@ class Workflow(DictConvertible): self, checkpoint_id: str, checkpoint_storage: CheckpointStorage | None, - responses: dict[str, Any], + responses: Mapping[str, Any], ) -> None: """Restore from a checkpoint then send responses to pending requests. @@ -700,7 +718,7 @@ class Workflow(DictConvertible): await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage) await self._send_responses_internal(responses) - async def _send_responses_internal(self, responses: dict[str, Any]) -> None: + async def _send_responses_internal(self, responses: Mapping[str, Any]) -> None: """Internal method to validate and send responses to the executors.""" pending_requests = await self._runner_context.get_pending_request_info_events() if not pending_requests: @@ -739,6 +757,44 @@ class Workflow(DictConvertible): raise ValueError(f"Executor with ID {executor_id} not found.") return self.executors[executor_id] + def _resolve_invocation_kwargs( + self, + kwargs: Mapping[str, Any], + param_name: str, + ) -> dict[str, Any]: + """Resolve invocation kwargs into a normalized per-executor or global format. + + Detects whether the provided kwargs dict uses per-executor targeting by checking + if any top-level key matches a known executor ID in the workflow. If at least one + key matches, all entries are treated as per-executor. Otherwise the dict is treated + as global kwargs that apply to every executor. + + Args: + kwargs: The raw invocation kwargs from the caller. + param_name: The parameter name (for logging), e.g. ``"function_invocation_kwargs"``. + + Returns: + A dict with either: + - ``{"__global__": }`` for global kwargs, or + - The original dict unchanged for per-executor kwargs. + """ + executor_ids = set(self.executors.keys()) + matched_ids = kwargs.keys() & executor_ids + if matched_ids: + logger.info( + "Detected per-executor %s: executor ID(s) %s found in keys. " + "All entries will be treated as per-executor.", + param_name, + matched_ids, + ) + return dict(kwargs) + + logger.info( + "No executor IDs found in %s keys; treating as global kwargs for all executors.", + param_name, + ) + return {GLOBAL_KWARGS_KEY: dict(kwargs)} + def _should_yield_output_event(self, event: WorkflowEvent[Any]) -> bool: """Determine if an output event should be yielded as a workflow output. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index e9e4196bfd..afb6145251 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ._workflow import Workflow from ._checkpoint_encoding import decode_checkpoint_value -from ._const import WORKFLOW_RUN_KWARGS_KEY +from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY from ._events import ( WorkflowEvent, WorkflowRunState, @@ -387,8 +387,28 @@ class WorkflowExecutor(Executor): # Get kwargs from parent workflow's State to propagate to subworkflow parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) + # Extract invocation kwargs recognised by Workflow.run() + # The state stores resolved format (with __global__ wrapper for global kwargs). + # Unwrap __global__ before passing to the subworkflow so it gets re-resolved + # against the subworkflow's own executor IDs. + fi_kwargs: dict[str, Any] | None = None + ci_kwargs: dict[str, Any] | None = None + for key in ("function_invocation_kwargs", "client_kwargs"): + resolved = parent_kwargs.get(key) + if isinstance(resolved, dict): + # Unwrap global sentinel; pass per-executor dicts as-is + unwrapped: dict[str, Any] = resolved.get(GLOBAL_KWARGS_KEY, resolved) # type: ignore + if key == "function_invocation_kwargs": + fi_kwargs = unwrapped # type: ignore + else: + ci_kwargs = unwrapped # type: ignore + # Run the sub-workflow and collect all events, passing parent kwargs - result = await self.workflow.run(input_data, **parent_kwargs) + result = await self.workflow.run( + input_data, + function_invocation_kwargs=fi_kwargs, # type: ignore + client_kwargs=ci_kwargs, # type: ignore + ) logger.debug( f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} " diff --git a/python/packages/core/agent_framework/amazon/__init__.py b/python/packages/core/agent_framework/amazon/__init__.py index e9282b8873..92eaa1ca5e 100644 --- a/python/packages/core/agent_framework/amazon/__init__.py +++ b/python/packages/core/agent_framework/amazon/__init__.py @@ -3,9 +3,11 @@ """Amazon Bedrock integration namespace for optional Agent Framework connectors. This module lazily re-exports objects from: +- ``agent-framework-anthropic`` - ``agent-framework-bedrock`` Supported classes: +- AnthropicBedrockClient - BedrockChatClient - BedrockChatOptions - BedrockEmbeddingClient @@ -13,34 +15,36 @@ Supported classes: - BedrockEmbeddingSettings - BedrockGuardrailConfig - BedrockSettings +- RawAnthropicBedrockClient """ import importlib from typing import Any -IMPORT_PATH = "agent_framework_bedrock" -PACKAGE_NAME = "agent-framework-bedrock" -_IMPORTS = [ - "BedrockChatClient", - "BedrockChatOptions", - "BedrockEmbeddingClient", - "BedrockEmbeddingOptions", - "BedrockEmbeddingSettings", - "BedrockGuardrailConfig", - "BedrockSettings", -] +_IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "BedrockChatClient": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockChatOptions": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockEmbeddingClient": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockEmbeddingOptions": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockEmbeddingSettings": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockGuardrailConfig": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockSettings": ("agent_framework_bedrock", "agent-framework-bedrock"), + "RawAnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), +} def __getattr__(name: str) -> Any: if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] try: - return getattr(importlib.import_module(IMPORT_PATH), name) + return getattr(importlib.import_module(import_path), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" + f"The '{package_name}' package is not installed, please do `pip install {package_name}`" ) from exc - raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") + raise AttributeError(f"Module `amazon` has no attribute {name}.") def __dir__() -> list[str]: - return _IMPORTS + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/amazon/__init__.pyi b/python/packages/core/agent_framework/amazon/__init__.pyi index a9dd7a9117..064639232a 100644 --- a/python/packages/core/agent_framework/amazon/__init__.pyi +++ b/python/packages/core/agent_framework/amazon/__init__.pyi @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +from agent_framework_anthropic import AnthropicBedrockClient, RawAnthropicBedrockClient from agent_framework_bedrock import ( BedrockChatClient, BedrockChatOptions, @@ -11,6 +12,7 @@ from agent_framework_bedrock import ( ) __all__ = [ + "AnthropicBedrockClient", "BedrockChatClient", "BedrockChatOptions", "BedrockEmbeddingClient", @@ -18,4 +20,5 @@ __all__ = [ "BedrockEmbeddingSettings", "BedrockGuardrailConfig", "BedrockSettings", + "RawAnthropicBedrockClient", ] diff --git a/python/packages/core/agent_framework/anthropic/__init__.py b/python/packages/core/agent_framework/anthropic/__init__.py index 8be2a7d208..9d2baaa088 100644 --- a/python/packages/core/agent_framework/anthropic/__init__.py +++ b/python/packages/core/agent_framework/anthropic/__init__.py @@ -7,22 +7,36 @@ This module lazily re-exports objects from: - ``agent-framework-claude`` Supported classes: +- AnthropicBedrockClient - AnthropicClient - AnthropicChatOptions +- AnthropicFoundryClient +- AnthropicVertexClient - ClaudeAgent - ClaudeAgentOptions +- RawAnthropicBedrockClient +- RawAnthropicClient +- RawAnthropicFoundryClient - RawClaudeAgent +- RawAnthropicVertexClient """ import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "AnthropicClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "AnthropicChatOptions": ("agent_framework_anthropic", "agent-framework-anthropic"), + "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "AnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "ClaudeAgent": ("agent_framework_claude", "agent-framework-claude"), "ClaudeAgentOptions": ("agent_framework_claude", "agent-framework-claude"), + "RawAnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "RawAnthropicClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "RawAnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "RawClaudeAgent": ("agent_framework_claude", "agent-framework-claude"), + "RawAnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), } diff --git a/python/packages/core/agent_framework/anthropic/__init__.pyi b/python/packages/core/agent_framework/anthropic/__init__.pyi index 29d70f62b8..1fa62c7f1b 100644 --- a/python/packages/core/agent_framework/anthropic/__init__.pyi +++ b/python/packages/core/agent_framework/anthropic/__init__.pyi @@ -1,14 +1,28 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework_anthropic import ( + AnthropicBedrockClient, AnthropicChatOptions, AnthropicClient, + AnthropicFoundryClient, + AnthropicVertexClient, + RawAnthropicBedrockClient, + RawAnthropicClient, + RawAnthropicFoundryClient, + RawAnthropicVertexClient, ) from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions __all__ = [ + "AnthropicBedrockClient", "AnthropicChatOptions", "AnthropicClient", + "AnthropicFoundryClient", + "AnthropicVertexClient", "ClaudeAgent", "ClaudeAgentOptions", + "RawAnthropicBedrockClient", + "RawAnthropicClient", + "RawAnthropicFoundryClient", + "RawAnthropicVertexClient", ] diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 1748ada1a3..7cff0150f1 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -12,26 +12,9 @@ _IMPORTS: dict[str, tuple[str, str]] = { "AgentCallbackContext": ("agent_framework_durabletask", "agent-framework-durabletask"), "AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"), "AgentResponseCallbackProtocol": ("agent_framework_durabletask", "agent-framework-durabletask"), - "AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIProjectAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIProjectAgentProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), - "AzureAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIAgentsProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureCredentialTypes": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureTokenProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIAssistantsClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIAssistantsOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIChatClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIChatOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIEmbeddingClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIResponsesClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIResponsesOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureUserSecurityContext": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "CosmosHistoryProvider": ("agent_framework_azure_cosmos", "agent-framework-azure-cosmos"), "DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"), diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index cdd3b66046..12527b6db3 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -3,30 +3,11 @@ # Type stubs for the agent_framework.azure lazy-loading namespace. # Install the relevant packages for full type support. -from agent_framework_azure_ai import ( - AzureAIAgentClient, - AzureAIAgentsProvider, - AzureAIClient, - AzureAIProjectAgentOptions, - AzureAIProjectAgentProvider, - AzureAISettings, - AzureCredentialTypes, - AzureOpenAIAssistantsClient, - AzureOpenAIAssistantsOptions, - AzureOpenAIChatClient, - AzureOpenAIChatOptions, - AzureOpenAIEmbeddingClient, - AzureOpenAIResponsesClient, - AzureOpenAIResponsesOptions, - AzureOpenAISettings, - AzureTokenProvider, - AzureUserSecurityContext, - RawAzureAIClient, -) from agent_framework_azure_ai_search import ( AzureAISearchContextProvider, AzureAISearchSettings, ) +from agent_framework_azure_cosmos import CosmosHistoryProvider from agent_framework_azurefunctions import AgentFunctionApp from agent_framework_durabletask import ( AgentCallbackContext, @@ -41,28 +22,11 @@ __all__ = [ "AgentCallbackContext", "AgentFunctionApp", "AgentResponseCallbackProtocol", - "AzureAIAgentClient", - "AzureAIAgentsProvider", - "AzureAIClient", - "AzureAIProjectAgentOptions", - "AzureAIProjectAgentProvider", "AzureAISearchContextProvider", "AzureAISearchSettings", - "AzureAISettings", - "AzureCredentialTypes", - "AzureOpenAIAssistantsClient", - "AzureOpenAIAssistantsOptions", - "AzureOpenAIChatClient", - "AzureOpenAIChatOptions", - "AzureOpenAIEmbeddingClient", - "AzureOpenAIResponsesClient", - "AzureOpenAIResponsesOptions", - "AzureOpenAISettings", - "AzureTokenProvider", - "AzureUserSecurityContext", + "CosmosHistoryProvider", "DurableAIAgent", "DurableAIAgentClient", "DurableAIAgentOrchestrationContext", "DurableAIAgentWorker", - "RawAzureAIClient", ] diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 0ebf0a9389..b1d2b88450 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -2,24 +2,33 @@ """Foundry integration namespace for optional Agent Framework connectors. -This module lazily re-exports objects from cloud Foundry and Foundry Local connector packages. +This module lazily re-exports objects from: +- ``agent-framework-anthropic`` +- ``agent-framework-foundry`` +- ``agent-framework-foundry-local`` """ import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryEmbeddingOptions": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryEmbeddingSettings": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryEvals": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"), "FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"), "FoundryLocalSettings": ("agent_framework_foundry_local", "agent-framework-foundry-local"), + "RawAnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "RawFoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), + "RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), "evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"), "evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"), } diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index 534b7fa5bc..47eb92b3af 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -3,15 +3,20 @@ # Type stubs for the agent_framework.foundry lazy-loading namespace. # Install the relevant packages for full type support. +from agent_framework_anthropic import AnthropicFoundryClient, RawAnthropicFoundryClient from agent_framework_foundry import ( FoundryAgent, FoundryChatClient, FoundryChatOptions, + FoundryEmbeddingClient, + FoundryEmbeddingOptions, + FoundryEmbeddingSettings, FoundryEvals, FoundryMemoryProvider, RawFoundryAgent, RawFoundryAgentChatClient, RawFoundryChatClient, + RawFoundryEmbeddingClient, evaluate_foundry_target, evaluate_traces, ) @@ -22,17 +27,23 @@ from agent_framework_foundry_local import ( ) __all__ = [ + "AnthropicFoundryClient", "FoundryAgent", "FoundryChatClient", "FoundryChatOptions", + "FoundryEmbeddingClient", + "FoundryEmbeddingOptions", + "FoundryEmbeddingSettings", "FoundryEvals", "FoundryLocalChatOptions", "FoundryLocalClient", "FoundryLocalSettings", "FoundryMemoryProvider", + "RawAnthropicFoundryClient", "RawFoundryAgent", "RawFoundryAgentChatClient", "RawFoundryChatClient", + "RawFoundryEmbeddingClient", "evaluate_foundry_target", "evaluate_traces", ] diff --git a/python/packages/core/agent_framework/google/__init__.py b/python/packages/core/agent_framework/google/__init__.py new file mode 100644 index 0000000000..20bcc591c8 --- /dev/null +++ b/python/packages/core/agent_framework/google/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Google integration namespace for optional Agent Framework connectors. + +This module lazily re-exports Google-hosted Anthropic clients from: +- ``agent-framework-anthropic`` + +Supported classes: +- AnthropicVertexClient +- RawAnthropicVertexClient +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "RawAnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{package_name}' package is not installed, please do `pip install {package_name}`" + ) from exc + raise AttributeError(f"Module `google` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/google/__init__.pyi b/python/packages/core/agent_framework/google/__init__.pyi new file mode 100644 index 0000000000..350f3cd328 --- /dev/null +++ b/python/packages/core/agent_framework/google/__init__.pyi @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_anthropic import AnthropicVertexClient, RawAnthropicVertexClient + +__all__ = [ + "AnthropicVertexClient", + "RawAnthropicVertexClient", +] diff --git a/python/packages/core/agent_framework/microsoft/__init__.py b/python/packages/core/agent_framework/microsoft/__init__.py index cf61e518d9..c7239a0c3b 100644 --- a/python/packages/core/agent_framework/microsoft/__init__.py +++ b/python/packages/core/agent_framework/microsoft/__init__.py @@ -5,7 +5,6 @@ This module lazily re-exports objects from: - ``agent-framework-copilotstudio`` - ``agent-framework-purview`` -- ``agent-framework-foundry-local`` Supported classes: - CopilotStudioAgent @@ -20,9 +19,6 @@ Supported classes: - PurviewRequestError - PurviewServiceError - CacheProvider -- FoundryLocalChatOptions -- FoundryLocalClient -- FoundryLocalSettings """ @@ -43,9 +39,6 @@ _IMPORTS: dict[str, tuple[str, str]] = { "PurviewRequestError": ("agent_framework_purview", "agent-framework-purview"), "PurviewServiceError": ("agent_framework_purview", "agent-framework-purview"), "CacheProvider": ("agent_framework_purview", "agent-framework-purview"), - "FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"), - "FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"), - "FoundryLocalSettings": ("agent_framework_foundry_local", "agent-framework-foundry-local"), } diff --git a/python/packages/core/agent_framework/microsoft/__init__.pyi b/python/packages/core/agent_framework/microsoft/__init__.pyi index be3abe523c..e70502ad3a 100644 --- a/python/packages/core/agent_framework/microsoft/__init__.pyi +++ b/python/packages/core/agent_framework/microsoft/__init__.pyi @@ -4,11 +4,6 @@ from agent_framework_copilotstudio import ( CopilotStudioAgent, acquire_token, ) -from agent_framework_foundry_local import ( - FoundryLocalChatOptions, - FoundryLocalClient, - FoundryLocalSettings, -) from agent_framework_purview import ( CacheProvider, PurviewAppLocation, @@ -26,9 +21,6 @@ from agent_framework_purview import ( __all__ = [ "CacheProvider", "CopilotStudioAgent", - "FoundryLocalChatOptions", - "FoundryLocalClient", - "FoundryLocalSettings", "PurviewAppLocation", "PurviewAuthenticationError", "PurviewChatPolicyMiddleware", diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 236daa29a0..8d2eb05136 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1265,15 +1265,13 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): opts: dict[str, Any] = options or {} # type: ignore[assignment] provider_name = str(getattr(self, "otel_provider_name", "unknown")) - model_id = ( - merged_client_kwargs.get("model_id") or opts.get("model_id") or getattr(self, "model_id", None) or "unknown" - ) + model = merged_client_kwargs.get("model") or opts.get("model") or getattr(self, "model", None) or "unknown" service_url_func = getattr(self, "service_url", None) service_url = str(service_url_func() if callable(service_url_func) else "unknown") attributes = _get_span_attributes( operation_name=OtelAttr.CHAT_COMPLETION_OPERATION, provider_name=provider_name, - model=model_id, + model=model, service_url=service_url, **merged_client_kwargs, ) @@ -1449,13 +1447,13 @@ class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOpti opts: dict[str, Any] = options or {} # type: ignore[assignment] provider_name = str(getattr(self, "otel_provider_name", "unknown")) - model_id = opts.get("model_id") or getattr(self, "model_id", None) or "unknown" + model = opts.get("model") or getattr(self, "model", None) or "unknown" service_url_func = getattr(self, "service_url", None) service_url = str(service_url_func() if callable(service_url_func) else "unknown") attributes = _get_span_attributes( operation_name=OtelAttr.EMBEDDING_OPERATION, provider_name=provider_name, - model=model_id, + model=model, service_url=service_url, ) @@ -1502,6 +1500,161 @@ class AgentTelemetryLayer: self.token_usage_histogram = _get_token_usage_histogram() self.duration_histogram = _get_duration_histogram() + def _trace_agent_invocation( + self, + *, + messages: AgentRunInputs | None, + session: AgentSession | None, + merged_options: Mapping[str, Any], + client_kwargs: Mapping[str, Any] | None, + stream: bool, + execute: Callable[[], Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]], + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Trace an agent invocation while delegating execution to ``execute``.""" + global OBSERVABILITY_SETTINGS + from ._types import ResponseStream + + if not OBSERVABILITY_SETTINGS.ENABLED: + return execute() + + provider_name = str(self.otel_provider_name) + merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + attributes = _get_span_attributes( + operation_name=OtelAttr.AGENT_INVOKE_OPERATION, + provider_name=provider_name, + agent_id=getattr(self, "id", "unknown"), + agent_name=getattr(self, "name", None) or getattr(self, "id", "unknown"), + agent_description=getattr(self, "description", None), + thread_id=session.service_session_id if session else None, + all_options=dict(merged_options), + **merged_client_kwargs, + ) + + inner_response_telemetry_captured_fields: set[str] = set() + inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set( + inner_response_telemetry_captured_fields + ) + inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({}) + + if stream: + try: + run_result: object = execute() + if isinstance(run_result, ResponseStream): + result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] + elif isinstance(run_result, Awaitable): + result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + else: + raise RuntimeError("Streaming telemetry requires a ResponseStream result.") + except Exception: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + raise + + operation = attributes.get(OtelAttr.OPERATION, "operation") + span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") + span = get_tracer().start_span(f"{operation} {span_name}") + span.set_attributes(attributes) + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=_get_instructions_from_options(dict(merged_options)), + ) + + span_state = {"closed": False} + duration_state: dict[str, float] = {} + start_time = perf_counter() + + def _close_span() -> None: + if span_state["closed"]: + return + span_state["closed"] = True + span.end() + + def _record_duration() -> None: + duration_state["duration"] = perf_counter() - start_time + + async def _finalize_stream() -> None: + from ._types import AgentResponse + + try: + response: AgentResponse[Any] = await result_stream.get_final_response() + duration = duration_state.get("duration") + response_attributes = _get_response_attributes( + attributes, + response, + capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD + not in inner_response_telemetry_captured_fields, + capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, + ) + _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) + _capture_response(span=span, attributes=response_attributes, duration=duration) + if ( + OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED + and isinstance(response, AgentResponse) + and response.messages + ): + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + output=True, + ) + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + finally: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + _close_span() + + wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook( + _record_duration + ).with_cleanup_hook(_finalize_stream) + weakref.finalize(wrapped_stream, _close_span) + return wrapped_stream + + async def _run() -> AgentResponse[Any]: + try: + with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=_get_instructions_from_options(dict(merged_options)), + ) + start_time_stamp = perf_counter() + try: + response: AgentResponse[Any] = await execute() + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + raise + duration = perf_counter() - start_time_stamp + if response: + response_attributes = _get_response_attributes( + attributes, + response, + capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD + not in inner_response_telemetry_captured_fields, + capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, + ) + _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) + _capture_response(span=span, attributes=response_attributes, duration=duration) + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + output=True, + ) + return response # type: ignore[return-value,no-any-return] + finally: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + + return _run() + @overload def run( self, @@ -1565,14 +1718,12 @@ class AgentTelemetryLayer: client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Trace agent runs with OpenTelemetry spans and metrics.""" - global OBSERVABILITY_SETTINGS - from ._types import ResponseStream, merge_chat_options + from ._types import merge_chat_options super_run = cast( "Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]", super().run, # type: ignore[misc] ) - provider_name = str(self.otel_provider_name) super_run_kwargs: dict[str, Any] = { "messages": messages, "stream": stream, @@ -1586,156 +1737,21 @@ class AgentTelemetryLayer: } if middleware is not None: super_run_kwargs["middleware"] = middleware - if not OBSERVABILITY_SETTINGS.ENABLED: - return super_run(**super_run_kwargs) # type: ignore[no-any-return] default_options = dict(getattr(self, "default_options", {})) merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} merged_options: dict[str, Any] = merge_chat_options( default_options, dict(options) if options is not None else {} ) - attributes = _get_span_attributes( - operation_name=OtelAttr.AGENT_INVOKE_OPERATION, - provider_name=provider_name, - agent_id=getattr(self, "id", "unknown"), - agent_name=getattr(self, "name", None) or getattr(self, "id", "unknown"), - agent_description=getattr(self, "description", None), - thread_id=session.service_session_id if session else None, - all_options=merged_options, - **merged_client_kwargs, + return self._trace_agent_invocation( + messages=messages, + session=session, + merged_options=merged_options, + client_kwargs=merged_client_kwargs, + stream=stream, + execute=lambda: super_run(**super_run_kwargs), ) - inner_response_telemetry_captured_fields: set[str] = set() - inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set( - inner_response_telemetry_captured_fields - ) - inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({}) - - if stream: - try: - run_result: object = super_run(**super_run_kwargs) - if isinstance(run_result, ResponseStream): - result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] - elif isinstance(run_result, Awaitable): - result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] - else: - raise RuntimeError("Streaming telemetry requires a ResponseStream result.") - except Exception: - INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) - INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) - raise - - # Create span directly without trace.use_span() context attachment. - # Streaming spans are closed asynchronously in cleanup hooks, which run - # in a different async context than creation — using use_span() would - # cause "Failed to detach context" errors from OpenTelemetry. - operation = attributes.get(OtelAttr.OPERATION, "operation") - span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") - span = get_tracer().start_span(f"{operation} {span_name}") - span.set_attributes(attributes) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - _capture_messages( - span=span, - provider_name=provider_name, - messages=messages, - system_instructions=_get_instructions_from_options(merged_options), - ) - - span_state = {"closed": False} - duration_state: dict[str, float] = {} - start_time = perf_counter() - - def _close_span() -> None: - if span_state["closed"]: - return - span_state["closed"] = True - span.end() - - def _record_duration() -> None: - duration_state["duration"] = perf_counter() - start_time - - async def _finalize_stream() -> None: - from ._types import AgentResponse - - try: - response: AgentResponse[Any] = await result_stream.get_final_response() - duration = duration_state.get("duration") - response_attributes = _get_response_attributes( - attributes, - response, - capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD - not in inner_response_telemetry_captured_fields, - capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, - ) - _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) - _capture_response(span=span, attributes=response_attributes, duration=duration) - if ( - OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED - and isinstance(response, AgentResponse) - and response.messages - ): - _capture_messages( - span=span, - provider_name=provider_name, - messages=response.messages, - output=True, - ) - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) - finally: - INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) - INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) - _close_span() - - # Register a weak reference callback to close the span if stream is garbage collected - # without being consumed. This ensures spans don't leak if users don't consume streams. - wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook( - _record_duration - ).with_cleanup_hook(_finalize_stream) - weakref.finalize(wrapped_stream, _close_span) - return wrapped_stream - - async def _run() -> AgentResponse: - try: - with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - _capture_messages( - span=span, - provider_name=provider_name, - messages=messages, - system_instructions=_get_instructions_from_options(merged_options), - ) - start_time_stamp = perf_counter() - try: - response: AgentResponse[Any] = await super_run(**super_run_kwargs) - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) - raise - duration = perf_counter() - start_time_stamp - if response: - response_attributes = _get_response_attributes( - attributes, - response, - capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD - not in inner_response_telemetry_captured_fields, - capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, - ) - _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) - _capture_response(span=span, attributes=response_attributes, duration=duration) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: - _capture_messages( - span=span, - provider_name=provider_name, - messages=response.messages, - output=True, - ) - return response # type: ignore[return-value,no-any-return] - finally: - INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) - INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) - - return _run() - # region Otel Helpers @@ -1848,8 +1864,7 @@ OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | Non "agent_id": (OtelAttr.AGENT_ID, None, False, None), "agent_name": (OtelAttr.AGENT_NAME, None, False, None), "agent_description": (OtelAttr.AGENT_DESCRIPTION, None, False, None), - # Multiple source keys - checks model_id in options, then model in kwargs, then model_id in kwargs - ("model_id", "model"): (OtelAttr.REQUEST_MODEL, None, True, None), + "model": (OtelAttr.REQUEST_MODEL, None, True, None), # Tools with validation - returns None if no valid tools "tools": ( OtelAttr.TOOL_DEFINITIONS, @@ -2036,8 +2051,8 @@ def _get_response_attributes( ) if finish_reason: attributes[OtelAttr.FINISH_REASONS] = json.dumps([finish_reason]) - if model_id := getattr(response, "model_id", None): - attributes[OtelAttr.RESPONSE_MODEL] = model_id + if model := getattr(response, "model", None): + attributes[OtelAttr.RESPONSE_MODEL] = model if capture_usage and (usage := response.usage_details): input_tokens = usage.get("input_token_count") if input_tokens: diff --git a/python/packages/core/agent_framework/openai/__init__.py b/python/packages/core/agent_framework/openai/__init__.py index 90bc732bae..949e9cd5ed 100644 --- a/python/packages/core/agent_framework/openai/__init__.py +++ b/python/packages/core/agent_framework/openai/__init__.py @@ -9,7 +9,6 @@ Supported classes include: - OpenAIChatClient (Responses API) - OpenAIChatCompletionClient (Chat Completions API) - OpenAIEmbeddingClient -- OpenAIAssistantsClient (deprecated) """ import importlib @@ -28,13 +27,6 @@ _IMPORTS: dict[str, tuple[str, str]] = { "OpenAISettings": ("agent_framework_openai", "agent-framework-openai"), "ContentFilterResultSeverity": ("agent_framework_openai", "agent-framework-openai"), "OpenAIContentFilterException": ("agent_framework_openai", "agent-framework-openai"), - "AssistantToolResources": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIAssistantProvider": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIAssistantsClient": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIAssistantsOptions": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIResponsesClient": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIResponsesOptions": ("agent_framework_openai", "agent-framework-openai"), - "RawOpenAIResponsesClient": ("agent_framework_openai", "agent-framework-openai"), } diff --git a/python/packages/core/agent_framework/openai/__init__.pyi b/python/packages/core/agent_framework/openai/__init__.pyi index 3f7ad148fb..e2c9a29ef8 100644 --- a/python/packages/core/agent_framework/openai/__init__.pyi +++ b/python/packages/core/agent_framework/openai/__init__.pyi @@ -4,11 +4,7 @@ # Install agent-framework-openai for full type support. from agent_framework_openai import ( - AssistantToolResources, ContentFilterResultSeverity, - OpenAIAssistantProvider, - OpenAIAssistantsClient, - OpenAIAssistantsOptions, OpenAIChatClient, OpenAIChatCompletionClient, OpenAIChatCompletionOptions, @@ -17,20 +13,13 @@ from agent_framework_openai import ( OpenAIContinuationToken, OpenAIEmbeddingClient, OpenAIEmbeddingOptions, - OpenAIResponsesClient, - OpenAIResponsesOptions, OpenAISettings, RawOpenAIChatClient, RawOpenAIChatCompletionClient, - RawOpenAIResponsesClient, ) __all__ = [ - "AssistantToolResources", "ContentFilterResultSeverity", - "OpenAIAssistantProvider", - "OpenAIAssistantsClient", - "OpenAIAssistantsOptions", "OpenAIChatClient", "OpenAIChatCompletionClient", "OpenAIChatCompletionOptions", @@ -39,10 +28,7 @@ __all__ = [ "OpenAIContinuationToken", "OpenAIEmbeddingClient", "OpenAIEmbeddingOptions", - "OpenAIResponsesClient", - "OpenAIResponsesOptions", "OpenAISettings", "RawOpenAIChatClient", "RawOpenAIChatCompletionClient", - "RawOpenAIResponsesClient", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index f002567381..e4aa1f5e40 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc6" +version = "1.0.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -35,10 +35,10 @@ all = [ "agent-framework-a2a", "agent-framework-ag-ui", "agent-framework-azure-ai-search", + "agent-framework-azure-cosmos", "agent-framework-anthropic", "agent-framework-openai", "agent-framework-claude", - "agent-framework-azure-ai", "agent-framework-azurefunctions", "agent-framework-bedrock", "agent-framework-chatkit", diff --git a/python/packages/core/tests/core/conftest.py b/python/packages/core/tests/core/conftest.py index 57c0cf5217..3841db32c3 100644 --- a/python/packages/core/tests/core/conftest.py +++ b/python/packages/core/tests/core/conftest.py @@ -3,6 +3,7 @@ import asyncio import logging import sys +import warnings from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence from typing import Any, Generic from unittest.mock import patch @@ -10,7 +11,13 @@ from uuid import uuid4 from pytest import fixture -from agent_framework import ( +warnings.filterwarnings( + "ignore", + message=r"\[SKILLS\].*", + category=FutureWarning, +) + +from agent_framework import ( # noqa: E402 AgentResponse, AgentResponseUpdate, AgentSession, @@ -26,8 +33,8 @@ from agent_framework import ( SupportsAgentRun, tool, ) -from agent_framework._clients import OptionsCoT -from agent_framework.observability import ChatTelemetryLayer +from agent_framework._clients import OptionsCoT # noqa: E402 +from agent_framework.observability import ChatTelemetryLayer # noqa: E402 if sys.version_info >= (3, 12): from typing import override # type: ignore @@ -98,7 +105,7 @@ class MockChatClient: self.call_count += 1 if self.responses: return self.responses.pop(0) - return ChatResponse(messages=Message(role="assistant", text="test response")) + return ChatResponse(messages=Message(role="assistant", contents=["test response"])) return _get() @@ -120,9 +127,7 @@ class MockChatClient: yield ChatResponseUpdate(contents=[Content.from_text("another update")], role="assistant") def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=options.get("response_format")) return ResponseStream(_stream(), finalizer=_finalize) @@ -181,7 +186,7 @@ class MockBaseChatClient( logger.debug(f"Running base chat client inner, with: {messages=}, {options=}, {kwargs=}") self.call_count += 1 if not self.run_responses: - return ChatResponse(messages=Message(role="assistant", text=f"test response - {messages[-1].text}")) + return ChatResponse(messages=Message(role="assistant", contents=[f"test response - {messages[-1].text}"])) response = self.run_responses.pop(0) @@ -189,7 +194,7 @@ class MockBaseChatClient( return ChatResponse( messages=Message( role="assistant", - text="I broke out of the function invocation loop...", + contents=["I broke out of the function invocation loop..."], ), conversation_id=response.conversation_id, ) @@ -226,9 +231,7 @@ class MockBaseChatClient( await asyncio.sleep(0) def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=options.get("response_format")) return ResponseStream(_stream(), finalizer=_finalize) diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 94253b3c34..c7b3d7860c 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -3,8 +3,8 @@ import contextlib import inspect import json -from collections.abc import AsyncIterable, MutableSequence -from typing import Any +from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -18,22 +18,29 @@ from agent_framework import ( AgentResponse, AgentResponseUpdate, AgentSession, - BaseContextProvider, + ChatContext, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + ContextProvider, FunctionTool, + HistoryProvider, + InMemoryHistoryProvider, Message, + ResponseStream, + SessionContext, SlidingWindowStrategy, SupportsAgentRun, SupportsChatGetResponse, TruncationStrategy, + chat_middleware, tool, ) from agent_framework._agents import _get_tool_name, _merge_options, _sanitize_agent_name from agent_framework._mcp import MCPTool, _build_prefixed_mcp_name, _normalize_mcp_name from agent_framework._middleware import FunctionInvocationContext +from agent_framework.exceptions import AgentInvalidRequestException, ChatClientInvalidResponseException class _FixedTokenizer: @@ -68,6 +75,49 @@ class _ConnectedMCPTool(MCPTool): raise NotImplementedError +class _RecordingHistoryProvider(HistoryProvider): + def __init__(self, source_id: str = "recording_history") -> None: + super().__init__(source_id=source_id) + + async def get_messages( + self, + session_id: str | None, + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Message]: + if state is None: + return [] + state["get_call_count"] = state.get("get_call_count", 0) + 1 + return list(cast(list[Message], state.get("messages", []))) + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + if state is None: + return + state["save_call_count"] = state.get("save_call_count", 0) + 1 + state.setdefault("messages", []).extend(messages) + + +class _ResponseIdRecordingHistoryProvider(_RecordingHistoryProvider): + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + state.setdefault("response_ids", []).append(context.response.response_id if context.response else None) + await super().after_run(agent=agent, session=session, context=context, state=state) + + def test_agent_session_type(agent_session: AgentSession) -> None: assert isinstance(agent_session, AgentSession) @@ -104,6 +154,24 @@ def test_chat_client_agent_type(client: SupportsChatGetResponse) -> None: assert isinstance(chat_client_agent, SupportsAgentRun) +def test_chat_client_agent_uses_client_model_attribute(chat_client_base) -> None: + chat_client_base.model = "claude-model" # type: ignore[attr-defined] + + agent = Agent(client=chat_client_base) + + assert agent.default_options["model"] == "claude-model" + assert "model_id" not in agent.default_options + + +def test_chat_client_agent_prefers_default_model_over_client_model(chat_client_base) -> None: + chat_client_base.model = "legacy-model" # type: ignore[attr-defined] + + agent = Agent(client=chat_client_base, default_options={"model": "claude-model"}) + + assert agent.default_options["model"] == "claude-model" + assert "model_id" not in agent.default_options + + def test_agent_init_docstring_surfaces_raw_agent_constructor_docs() -> None: docstring = inspect.getdoc(Agent.__init__) @@ -233,6 +301,56 @@ async def test_chat_client_agent_streaming_response_format_from_run_options( assert result.value.greeting == "Hi" +async def test_chat_client_agent_response_format_dict_from_default_options( + client: SupportsChatGetResponse, +) -> None: + """AgentResponse.value should parse JSON dicts from default_options response_format.""" + json_text = json.dumps({"greeting": "Hello"}) + client.responses.append(ChatResponse(messages=Message(role="assistant", contents=[json_text]))) # type: ignore[attr-defined] + + agent = Agent( + client=client, + default_options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}}, + ) + result = await agent.run("Hello") + + assert result.text == json_text + assert result.value is not None + assert isinstance(result.value, dict) + assert result.value["greeting"] == "Hello" + + +async def test_chat_client_agent_streaming_response_format_dict_from_run_options( + client: SupportsChatGetResponse, +) -> None: + """Agent streaming should preserve mapping response_format and parse the final value as a dict.""" + json_text = json.dumps({"greeting": "Hi"}) + client.streaming_responses.append( # type: ignore[attr-defined] + [ + ChatResponseUpdate( + contents=[Content.from_text(json_text)], + role="assistant", + finish_reason="stop", + ) + ] + ) + + agent = Agent(client=client) + stream = agent.run( + "Hello", + stream=True, + options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}}, + ) + async for _ in stream: + pass + result = await stream.get_final_response() + + assert result.text == json_text + assert result.value is not None + assert isinstance(result.value, dict) + assert result.value["greeting"] == "Hi" + + async def test_chat_client_agent_create_session( client: SupportsChatGetResponse, ) -> None: @@ -248,13 +366,13 @@ async def test_chat_client_agent_prepare_session_and_messages( from agent_framework._sessions import InMemoryHistoryProvider agent = Agent(client=client, context_providers=[InMemoryHistoryProvider()]) - message = Message(role="user", text="Hello") + message = Message(role="user", contents=["Hello"]) session = AgentSession() session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID] = {"messages": [message]} session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] session=session, - input_messages=[Message(role="user", text="Test")], + input_messages=[Message(role="user", contents=["Test"])], ) result_messages = session_context.get_messages(include_input=True) @@ -275,7 +393,7 @@ async def test_prepare_session_does_not_mutate_agent_chat_options( _, prepared_chat_options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] session=session, - input_messages=[Message(role="user", text="Test")], + input_messages=[Message(role="user", contents=["Test"])], ) assert prepared_chat_options.get("tools") is not None @@ -314,6 +432,415 @@ async def test_prepare_run_context_handles_function_kwargs( assert ctx["client_kwargs"]["session"] is session +async def test_chat_agent_persists_history_per_service_call( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + session = AgentSession() + session.state[provider.source_id] = { + "messages": [ + Message(role="user", contents=["Earlier question"]), + Message(role="assistant", contents=["Earlier answer"]), + ] + } + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + response_id="resp_call_1", + ), + ChatResponse( + messages=Message(role="assistant", contents=["It is sunny in Seattle."]), response_id="resp_call_2" + ), + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + result = await agent.run("What's the weather in Seattle?", session=session) + + provider_state = session.state[provider.source_id] + stored_messages = cast(list[Message], provider_state["messages"]) + + assert result.text == "It is sunny in Seattle." + assert result.response_id is None + assert chat_client_base.call_count == 2 + assert provider_state["get_call_count"] == 2 + assert provider_state["save_call_count"] == 2 + assert stored_messages[-1].text == "It is sunny in Seattle." + assert session.service_session_id is None + + +async def test_chat_agent_persists_history_per_service_call_streaming( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + session = AgentSession() + session.state[provider.source_id] = { + "messages": [ + Message(role="user", contents=["Earlier question"]), + Message(role="assistant", contents=["Earlier answer"]), + ] + } + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + finish_reason="stop", + response_id="resp_call_1", + ) + ], + [ + ChatResponseUpdate( + contents=[Content.from_text("It is sunny in Seattle.")], + role="assistant", + finish_reason="stop", + response_id="resp_call_2", + ) + ], + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + stream = agent.run("What's the weather in Seattle?", session=session, stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + + provider_state = session.state[provider.source_id] + stored_messages = cast(list[Message], provider_state["messages"]) + + assert result.text == "It is sunny in Seattle." + assert result.response_id is None + assert chat_client_base.call_count == 2 + assert provider_state["get_call_count"] == 2 + assert provider_state["save_call_count"] == 2 + assert stored_messages[-1].text == "It is sunny in Seattle." + assert session.service_session_id is None + + +async def test_streaming_per_service_call_persistence_hides_response_id_from_after_run( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _ResponseIdRecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + finish_reason="stop", + response_id="resp_call_1", + ) + ], + [ + ChatResponseUpdate( + contents=[Content.from_text("It is sunny in Seattle.")], + role="assistant", + finish_reason="stop", + response_id="resp_call_2", + ) + ], + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + stream = agent.run("What's the weather in Seattle?", session=session, stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + + provider_state = session.state[provider.source_id] + + assert result.response_id is None + assert provider_state["response_ids"] == [None, None] + + +async def test_per_service_call_persistence_uses_real_service_storage_when_client_stores_by_default( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + conversation_id="resp_service_managed", + response_id="resp_call_1", + ), + ChatResponse( + messages=Message(role="assistant", contents=["It is sunny in Seattle."]), + conversation_id="resp_service_managed", + response_id="resp_call_2", + ), + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + result = await agent.run("What's the weather in Seattle?", session=session) + + provider_state = session.state[provider.source_id] + + assert result.text == "It is sunny in Seattle." + assert result.response_id == "resp_call_2" + assert chat_client_base.call_count == 2 + assert "get_call_count" not in provider_state + assert "save_call_count" not in provider_state + assert session.service_session_id == "resp_service_managed" + + +async def test_service_storage_updates_session_handle_per_service_call_before_non_streaming_failure( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + first_response = ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + conversation_id="resp_call_1", + response_id="resp_call_1", + ) + mock_get_non_streaming_response = AsyncMock( + side_effect=[first_response, RuntimeError("service down")], + ) + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with ( + patch.object(chat_client_base, "_get_non_streaming_response", new=mock_get_non_streaming_response), + pytest.raises(RuntimeError, match="service down"), + ): + await agent.run("What's the weather in Seattle?", session=session) + + assert mock_get_non_streaming_response.await_count == 2 + assert session.service_session_id == "resp_call_1" + + +async def test_service_storage_updates_session_handle_per_service_call_before_streaming_failure( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + + async def _first_stream_updates() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + finish_reason="stop", + ) + + def _finalize_first_stream(_updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]: + return ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + conversation_id="resp_call_1", + response_id="resp_call_1", + ) + + first_stream = ResponseStream(_first_stream_updates(), finalizer=_finalize_first_stream) + mock_get_streaming_response = MagicMock(side_effect=[first_stream, RuntimeError("service down")]) + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with ( + patch.object(chat_client_base, "_get_streaming_response", new=mock_get_streaming_response), + pytest.raises(RuntimeError, match="service down"), + ): + stream = agent.run("What's the weather in Seattle?", session=session, stream=True) + async for _ in stream: + pass + + assert mock_get_streaming_response.call_count == 2 + assert session.service_session_id == "resp_call_1" + + +async def test_chat_agent_without_per_service_call_persistence_preserves_response_id( + chat_client_base: SupportsChatGetResponse, +) -> None: + chat_client_base.run_responses = [ + ChatResponse( + messages=Message(role="assistant", contents=["Hello"]), + response_id="resp_call_1", + ) + ] + + agent = Agent( + client=chat_client_base, + context_providers=[InMemoryHistoryProvider()], + ) + + result = await agent.run("Hello", session=AgentSession(), options={"store": False}) + + assert result.response_id == "resp_call_1" + + +async def test_per_service_call_persistence_rejects_real_service_conversation_id( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + chat_client_base.run_responses = [ + ChatResponse( + messages=Message(role="assistant", contents=["Hello"]), + conversation_id="resp_service_managed", + ) + ] + + agent = Agent( + client=chat_client_base, + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with pytest.raises( + ChatClientInvalidResponseException, + match="require_per_service_call_history_persistence cannot be used", + ): + await agent.run("Hello", session=session, options={"store": False}) + + +async def test_per_service_call_persistence_rejects_existing_conversation_id_when_service_not_storing_history( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + + agent = Agent( + client=chat_client_base, + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with pytest.raises( + AgentInvalidRequestException, + match="require_per_service_call_history_persistence cannot be used", + ): + await agent.run("Hello", session=session, options={"store": False, "conversation_id": "existing_conversation"}) + + async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChatGetResponse) -> None: mock_response = ChatResponse( messages=[Message(role="assistant", contents=[Content.from_text("test response")])], @@ -586,7 +1113,7 @@ async def test_chat_client_agent_author_name_is_used_from_response( # Mock context provider for testing -class MockContextProvider(BaseContextProvider): +class MockContextProvider(ContextProvider): def __init__(self, messages: list[Message] | None = None) -> None: super().__init__(source_id="mock") self.context_messages = messages @@ -613,7 +1140,7 @@ async def test_chat_agent_context_providers_model_before_run( client: SupportsChatGetResponse, ) -> None: """Test that context providers' before_run is called during agent run.""" - mock_provider = MockContextProvider(messages=[Message(role="system", text="Test context instructions")]) + mock_provider = MockContextProvider(messages=[Message(role="system", contents=["Test context instructions"])]) agent = Agent(client=client, context_providers=[mock_provider]) await agent.run("Hello") @@ -660,7 +1187,7 @@ async def test_chat_agent_context_instructions_in_messages( client: SupportsChatGetResponse, ) -> None: """Test that AI context instructions are included in messages.""" - mock_provider = MockContextProvider(messages=[Message(role="system", text="Context-specific instructions")]) + mock_provider = MockContextProvider(messages=[Message(role="system", contents=["Context-specific instructions"])]) agent = Agent( client=client, instructions="Agent instructions", @@ -669,7 +1196,7 @@ async def test_chat_agent_context_instructions_in_messages( # We need to test the _prepare_session_and_messages method directly session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] - session=None, input_messages=[Message(role="user", text="Hello")] + session=None, input_messages=[Message(role="user", contents=["Hello"])] ) messages = session_context.get_messages(include_input=True) @@ -694,7 +1221,7 @@ async def test_chat_agent_no_context_instructions( ) session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] - session=None, input_messages=[Message(role="user", text="Hello")] + session=None, input_messages=[Message(role="user", contents=["Hello"])] ) messages = session_context.get_messages(include_input=True) @@ -708,7 +1235,7 @@ async def test_chat_agent_run_stream_context_providers( client: SupportsChatGetResponse, ) -> None: """Test that context providers work with run method.""" - mock_provider = MockContextProvider(messages=[Message(role="system", text="Stream context instructions")]) + mock_provider = MockContextProvider(messages=[Message(role="system", contents=["Stream context instructions"])]) agent = Agent(client=client, context_providers=[mock_provider]) # Collect all stream updates and get final response @@ -1202,7 +1729,7 @@ async def test_agent_tool_without_context_does_not_receive_session(chat_client_b ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] agent = Agent(client=chat_client_base, tools=[echo_session_info]) @@ -1241,7 +1768,7 @@ async def test_agent_tool_receives_explicit_session_via_function_invocation_cont ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] agent = Agent(client=chat_client_base, tools=[capture_session_context]) @@ -1374,8 +1901,8 @@ async def test_chat_agent_compaction_overrides_client_defaults(chat_client_base: ) await agent.run([ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ]) assert captured_roles == [["user", "assistant"]] @@ -1399,8 +1926,8 @@ async def test_chat_agent_uses_client_compaction_defaults_when_agent_unset(chat_ agent = Agent(client=chat_client_base) await agent.run([ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ]) assert captured_roles == [["assistant"]] @@ -1432,8 +1959,8 @@ async def test_chat_agent_run_level_compaction_and_tokenizer_override_agent_defa await agent.run( [ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ], compaction_strategy=TruncationStrategy(max_n=1, compact_to=1), tokenizer=_FixedTokenizer(23), @@ -1469,6 +1996,20 @@ def test_merge_options_none_values_ignored(): assert result["key2"] == "value2" +def test_merge_options_runtime_model_overrides_default_model() -> None: + """Test _merge_options lets a runtime model override a default model.""" + result = _merge_options({"model": "default-model"}, {"model": "runtime-model"}) + + assert result["model"] == "runtime-model" + + +def test_merge_options_preserves_base_model_without_override() -> None: + """Test _merge_options preserves the base model when there is no override.""" + result = _merge_options({"model": "preferred-model"}, {}) + + assert result["model"] == "preferred-model" + + def test_merge_options_tools_combined(): """Test _merge_options raises when distinct tools share the same name.""" @@ -1723,7 +2264,7 @@ async def test_agent_create_session_with_context_providers( ): """Test that create_session works when context_providers are set on the agent.""" - class TestContextProvider(BaseContextProvider): + class TestContextProvider(ContextProvider): def __init__(self): super().__init__(source_id="test") @@ -1798,7 +2339,7 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none( """A tool provided by context.""" return text - class ToolContextProvider(BaseContextProvider): + class ToolContextProvider(ContextProvider): def __init__(self): super().__init__(source_id="tool-context") @@ -1813,7 +2354,7 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none( # Run the agent and verify context tools are added _, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] - session=None, input_messages=[Message(role="user", text="Hello")] + session=None, input_messages=[Message(role="user", contents=["Hello"])] ) # The context tools should now be in the options @@ -1827,7 +2368,7 @@ async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none ): """Test that context provider instructions are used when agent has no default instructions.""" - class InstructionContextProvider(BaseContextProvider): + class InstructionContextProvider(ContextProvider): def __init__(self): super().__init__(source_id="instruction-context") @@ -1842,13 +2383,40 @@ async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none # Run the agent and verify context instructions are available _, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] - session=None, input_messages=[Message(role="user", text="Hello")] + session=None, input_messages=[Message(role="user", contents=["Hello"])] ) # The context instructions should now be in the options assert options.get("instructions") == "Context-provided instructions" +async def test_chat_agent_context_provider_adds_middleware_when_agent_has_none( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Test that context provider middleware is collected during preparation.""" + + @chat_middleware + async def context_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + class MiddlewareContextProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="middleware-context") + + async def before_run(self, *, agent, session, context, state) -> None: + context.extend_middleware("middleware-context", context_chat_middleware) + + agent = Agent(client=chat_client_base, context_providers=[MiddlewareContextProvider()]) + + session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=None, + input_messages=[Message(role="user", contents=["Hello"])], + ) + + assert session_context.middleware["middleware-context"] == [context_chat_middleware] + assert session_context.get_middleware() == [context_chat_middleware] + + # region STORES_BY_DEFAULT tests diff --git a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py index 8aa71a4582..d8acc8ac1a 100644 --- a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py +++ b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py @@ -40,7 +40,7 @@ class TestAsToolKwargsPropagation: # Setup mock response client.responses = [ - ChatResponse(messages=[Message(role="assistant", text="Response from sub-agent")]), + ChatResponse(messages=[Message(role="assistant", contents=["Response from sub-agent"])]), ] # Create sub-agent with middleware @@ -82,7 +82,7 @@ class TestAsToolKwargsPropagation: # Setup mock response client.responses = [ - ChatResponse(messages=[Message(role="assistant", text="Response from sub-agent")]), + ChatResponse(messages=[Message(role="assistant", contents=["Response from sub-agent"])]), ] sub_agent = Agent( @@ -133,8 +133,8 @@ class TestAsToolKwargsPropagation: ) ] ), - ChatResponse(messages=[Message(role="assistant", text="Response from agent_c")]), - ChatResponse(messages=[Message(role="assistant", text="Response from agent_b")]), + ChatResponse(messages=[Message(role="assistant", contents=["Response from agent_c"])]), + ChatResponse(messages=[Message(role="assistant", contents=["Response from agent_b"])]), ] # Create agent C (bottom level) @@ -219,7 +219,7 @@ class TestAsToolKwargsPropagation: """Test that as_tool works correctly when no extra kwargs are provided.""" # Setup mock response client.responses = [ - ChatResponse(messages=[Message(role="assistant", text="Response from agent")]), + ChatResponse(messages=[Message(role="assistant", contents=["Response from agent"])]), ] sub_agent = Agent( @@ -248,7 +248,7 @@ class TestAsToolKwargsPropagation: # Setup mock response client.responses = [ - ChatResponse(messages=[Message(role="assistant", text="Response with options")]), + ChatResponse(messages=[Message(role="assistant", contents=["Response with options"])]), ] sub_agent = Agent( @@ -295,8 +295,8 @@ class TestAsToolKwargsPropagation: # Setup mock responses for both calls client.responses = [ - ChatResponse(messages=[Message(role="assistant", text="First response")]), - ChatResponse(messages=[Message(role="assistant", text="Second response")]), + ChatResponse(messages=[Message(role="assistant", contents=["First response"])]), + ChatResponse(messages=[Message(role="assistant", contents=["Second response"])]), ] sub_agent = Agent( @@ -342,7 +342,7 @@ class TestAsToolKwargsPropagation: # Setup mock response client.responses = [ - ChatResponse(messages=[Message(role="assistant", text="Response from sub-agent")]), + ChatResponse(messages=[Message(role="assistant", contents=["Response from sub-agent"])]), ] sub_agent = Agent( diff --git a/python/packages/core/tests/core/test_azure_namespace.py b/python/packages/core/tests/core/test_azure_namespace.py new file mode 100644 index 0000000000..9960b77ac9 --- /dev/null +++ b/python/packages/core/tests/core/test_azure_namespace.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_azure_cosmos import CosmosHistoryProvider + +import agent_framework.azure as azure + + +def test_azure_namespace_exposes_cosmos_history_provider() -> None: + assert azure.CosmosHistoryProvider is CosmosHistoryProvider + assert "CosmosHistoryProvider" in dir(azure) diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index 9a7e90f5ee..7657993d56 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -31,13 +31,13 @@ def test_chat_client_type(client: SupportsChatGetResponse): async def test_chat_client_get_response(client: SupportsChatGetResponse): - response = await client.get_response([Message(role="user", text="Hello")]) + response = await client.get_response([Message(role="user", contents=["Hello"])]) assert response.text == "test response" assert response.messages[0].role == "assistant" async def test_chat_client_get_response_streaming(client: SupportsChatGetResponse): - async for update in client.get_response([Message(role="user", text="Hello")], stream=True): + async for update in client.get_response([Message(role="user", contents=["Hello"])], stream=True): assert update.text == "test streaming response " or update.text == "another update" assert update.role == "assistant" @@ -62,7 +62,7 @@ async def test_base_client_get_response_uses_explicit_client_kwargs(chat_client_ async def fake_inner_get_response(**kwargs): assert kwargs["trace_id"] == "trace-123" assert "function_invocation_kwargs" not in kwargs - return ChatResponse(messages=[Message(role="assistant", text="ok")]) + return ChatResponse(messages=[Message(role="assistant", contents=["ok"])]) with patch.object( chat_client_base, @@ -70,7 +70,7 @@ async def test_base_client_get_response_uses_explicit_client_kwargs(chat_client_ side_effect=fake_inner_get_response, ) as mock_inner_get_response: await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], function_invocation_kwargs={"tool_request_id": "tool-123"}, client_kwargs={"trace_id": "trace-123"}, ) @@ -78,13 +78,13 @@ async def test_base_client_get_response_uses_explicit_client_kwargs(chat_client_ async def test_base_client_get_response(chat_client_base: SupportsChatGetResponse): - response = await chat_client_base.get_response([Message(role="user", text="Hello")]) + response = await chat_client_base.get_response([Message(role="user", contents=["Hello"])]) assert response.messages[0].role == "assistant" assert response.messages[0].text == "test response - Hello" async def test_base_client_get_response_streaming(chat_client_base: SupportsChatGetResponse): - async for update in chat_client_base.get_response([Message(role="user", text="Hello")], stream=True): + async for update in chat_client_base.get_response([Message(role="user", contents=["Hello"])], stream=True): assert update.text == "update - Hello" or update.text == "another update" @@ -107,8 +107,8 @@ async def test_base_client_applies_compaction_before_non_streaming_inner_call( chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign] await chat_client_base.get_response([ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ]) assert captured_roles == [["assistant"]] @@ -133,8 +133,8 @@ async def test_base_client_applies_compaction_before_streaming_inner_call( chat_client_base._get_streaming_response = _capture # type: ignore[attr-defined,method-assign] async for _ in chat_client_base.get_response( [ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ], stream=True, ): @@ -161,8 +161,8 @@ async def test_base_client_per_call_compaction_override_applies_before_inner_cal chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign] await chat_client_base.get_response( [ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ], compaction_strategy=TruncationStrategy(max_n=1, compact_to=1), ) @@ -191,8 +191,8 @@ async def test_base_client_per_call_tokenizer_override_annotates_messages( chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign] await chat_client_base.get_response( [ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ], compaction_strategy=SlidingWindowStrategy(keep_last_groups=2), tokenizer=_FixedTokenizer(17), @@ -222,8 +222,8 @@ async def test_base_client_per_call_tokenizer_override_without_strategy_annotate chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign] await chat_client_base.get_response( [ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ], tokenizer=_FixedTokenizer(17), ) @@ -252,8 +252,8 @@ async def test_base_client_default_tokenizer_without_strategy_annotates_messages chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign] await chat_client_base.get_response([ - Message(role="user", text="Hello"), - Message(role="assistant", text="Previous response"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Previous response"]), ]) assert captured_token_counts == [[19, 19]] @@ -276,7 +276,7 @@ async def test_chat_client_instructions_handling(chat_client_base: SupportsChatG instructions = "You are a helpful assistant." async def fake_inner_get_response(**kwargs): - return ChatResponse(messages=[Message(role="assistant", text="ok")]) + return ChatResponse(messages=[Message(role="assistant", contents=["ok"])]) with patch.object( chat_client_base, @@ -284,7 +284,7 @@ async def test_chat_client_instructions_handling(chat_client_base: SupportsChatG side_effect=fake_inner_get_response, ) as mock_inner_get_response: await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"instructions": instructions} + [Message(role="user", contents=["hello"])], options={"instructions": instructions} ) mock_inner_get_response.assert_called_once() _, kwargs = mock_inner_get_response.call_args @@ -296,7 +296,7 @@ async def test_chat_client_instructions_handling(chat_client_base: SupportsChatG from agent_framework._types import prepend_instructions_to_messages appended_messages = prepend_instructions_to_messages( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], instructions, ) assert len(appended_messages) == 2 diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index 0352529ec5..1db3260822 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -105,10 +105,10 @@ def _group_unknown_value(message: Message, key: str) -> Any: def test_group_annotations_keep_tool_call_and_tool_result_atomic() -> None: messages = [ - Message(role="user", text="hello"), + Message(role="user", contents=["hello"]), _assistant_function_call("c1"), _tool_result("c1", "ok"), - Message(role="assistant", text="final"), + Message(role="assistant", contents=["final"]), ] annotate_message_groups(messages) @@ -136,11 +136,11 @@ def test_group_annotations_include_reasoning_in_tool_call_group() -> None: def test_group_annotations_handle_same_message_reasoning_and_function_calls() -> None: messages = [ - Message(role="user", text="hello"), + Message(role="user", contents=["hello"]), _assistant_reasoning_and_function_calls("c1", "c2"), _tool_result("c1", "ok1"), _tool_result("c2", "ok2"), - Message(role="assistant", text="final"), + Message(role="assistant", contents=["final"]), ] annotate_message_groups(messages) @@ -155,8 +155,8 @@ def test_group_annotations_handle_same_message_reasoning_and_function_calls() -> def test_annotate_message_groups_with_tokenizer_adds_token_counts() -> None: messages = [ - Message(role="user", text="hello"), - Message(role="assistant", text="world"), + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["world"]), ] annotate_message_groups( @@ -187,9 +187,9 @@ def test_extend_compaction_messages_preserves_existing_annotations_and_tokens() def test_append_compaction_message_annotates_new_message() -> None: - messages = [Message(role="user", text="hello")] + messages = [Message(role="user", contents=["hello"])] annotate_message_groups(messages) - append_compaction_message(messages, Message(role="assistant", text="world")) + append_compaction_message(messages, Message(role="assistant", contents=["world"])) assert len(messages) == 2 assert isinstance(_group_id(messages[1]), str) @@ -197,11 +197,11 @@ def test_append_compaction_message_annotates_new_message() -> None: async def test_truncation_strategy_keeps_system_anchor() -> None: messages = [ - Message(role="system", text="you are helpful"), - Message(role="user", text="u1"), - Message(role="assistant", text="a1"), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), + Message(role="system", contents=["you are helpful"]), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"]), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), ] strategy = TruncationStrategy(max_n=3, compact_to=3, preserve_system=True) annotate_message_groups(messages) @@ -217,9 +217,9 @@ async def test_truncation_strategy_keeps_system_anchor() -> None: async def test_truncation_strategy_compacts_when_token_limit_exceeded() -> None: tokenizer = CharacterEstimatorTokenizer() messages = [ - Message(role="system", text="you are helpful"), - Message(role="user", text="u1 " * 200), - Message(role="assistant", text="a1 " * 200), + Message(role="system", contents=["you are helpful"]), + Message(role="user", contents=["u1 " * 200]), + Message(role="assistant", contents=["a1 " * 200]), ] strategy = TruncationStrategy( max_n=80, @@ -248,12 +248,12 @@ def test_truncation_strategy_validates_token_targets() -> None: async def test_selective_tool_call_strategy_excludes_older_tool_groups() -> None: messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), _assistant_function_call("call-1"), _tool_result("call-1", "r1"), _assistant_function_call("call-2"), _tool_result("call-2", "r2"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] strategy = SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=1) annotate_message_groups(messages) @@ -269,10 +269,10 @@ async def test_selective_tool_call_strategy_excludes_older_tool_groups() -> None async def test_selective_tool_call_strategy_with_zero_removes_assistant_tool_pair() -> None: messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), _assistant_function_call("call-1"), _tool_result("call-1", "r1"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] strategy = SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0) annotate_message_groups(messages) @@ -304,7 +304,7 @@ class _FakeSummarizer: options: dict[str, Any] | None = None, **kwargs: Any, ) -> ChatResponse: - return ChatResponse(messages=[Message(role="assistant", text="summarized context")]) + return ChatResponse(messages=[Message(role="assistant", contents=["summarized context"])]) class _FailingSummarizer: @@ -328,17 +328,17 @@ class _EmptySummarizer: options: dict[str, Any] | None = None, **kwargs: Any, ) -> ChatResponse: - return ChatResponse(messages=[Message(role="assistant", text=" ")]) + return ChatResponse(messages=[Message(role="assistant", contents=[" "])]) async def test_summarization_strategy_adds_bidirectional_trace_links() -> None: messages = [ - Message(role="user", text="u1"), - Message(role="assistant", text="a1"), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), - Message(role="user", text="u3"), - Message(role="assistant", text="a3"), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"]), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), + Message(role="user", contents=["u3"]), + Message(role="assistant", contents=["a3"]), ] strategy = SummarizationStrategy(client=_FakeSummarizer(), target_count=2, threshold=0) annotate_message_groups(messages) @@ -366,12 +366,12 @@ async def test_summarization_strategy_returns_false_when_summary_generation_fail caplog: Any, ) -> None: messages = [ - Message(role="user", text="u1"), - Message(role="assistant", text="a1"), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), - Message(role="user", text="u3"), - Message(role="assistant", text="a3"), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"]), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), + Message(role="user", contents=["u3"]), + Message(role="assistant", contents=["a3"]), ] strategy = SummarizationStrategy(client=_FailingSummarizer(), target_count=2, threshold=0) annotate_message_groups(messages) @@ -388,12 +388,12 @@ async def test_summarization_strategy_returns_false_when_summary_is_empty( caplog: Any, ) -> None: messages = [ - Message(role="user", text="u1"), - Message(role="assistant", text="a1"), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), - Message(role="user", text="u3"), - Message(role="assistant", text="a3"), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"]), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), + Message(role="user", contents=["u3"]), + Message(role="assistant", contents=["a3"]), ] strategy = SummarizationStrategy(client=_EmptySummarizer(), target_count=2, threshold=0) annotate_message_groups(messages) @@ -408,9 +408,9 @@ async def test_summarization_strategy_returns_false_when_summary_is_empty( async def test_token_budget_composed_strategy_meets_budget_or_falls_back() -> None: messages = [ - Message(role="system", text="system"), - Message(role="user", text="user " * 200), - Message(role="assistant", text="assistant " * 200), + Message(role="system", contents=["system"]), + Message(role="user", contents=["user " * 200]), + Message(role="assistant", contents=["assistant " * 200]), ] strategy = TokenBudgetComposedStrategy( token_budget=20, @@ -445,9 +445,9 @@ class _ExcludeOldestNonSystem: async def test_apply_compaction_projects_included_messages_only() -> None: messages = [ - Message(role="system", text="sys"), - Message(role="user", text="hello"), - Message(role="assistant", text="world"), + Message(role="system", contents=["sys"]), + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["world"]), ] projected = await apply_compaction(messages, strategy=_ExcludeOldestNonSystem()) @@ -462,12 +462,12 @@ async def test_apply_compaction_projects_included_messages_only() -> None: async def test_tool_result_compaction_collapses_old_groups_into_summary() -> None: """Old tool-call groups are collapsed into summary messages, newest kept.""" messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), _assistant_function_call("call-1"), _tool_result("call-1", "r1"), _assistant_function_call("call-2"), _tool_result("call-2", "r2"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) annotate_message_groups(messages) @@ -486,12 +486,12 @@ async def test_tool_result_compaction_collapses_old_groups_into_summary() -> Non async def test_tool_result_compaction_zero_collapses_all() -> None: """With keep=0, all tool-call groups are collapsed into summaries.""" messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), _assistant_function_call("call-1"), _tool_result("call-1", "r1"), _assistant_function_call("call-2"), _tool_result("call-2", "r2"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0) annotate_message_groups(messages) @@ -508,7 +508,7 @@ async def test_tool_result_compaction_zero_collapses_all() -> None: async def test_tool_result_compaction_no_change_when_within_limit() -> None: """No compaction when tool groups count does not exceed keep limit.""" messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), _assistant_function_call("call-1"), _tool_result("call-1", "r1"), ] @@ -532,7 +532,7 @@ def test_tool_result_compaction_rejects_negative() -> None: async def test_tool_result_compaction_preserves_tool_results_in_summary() -> None: """Summary text should include the tool results from the collapsed group.""" messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), Message( role="assistant", contents=[ @@ -542,7 +542,7 @@ async def test_tool_result_compaction_preserves_tool_results_in_summary() -> Non ), _tool_result("c1", "sunny"), _tool_result("c2", "found 3 docs"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0) annotate_message_groups(messages) @@ -559,10 +559,10 @@ async def test_tool_result_compaction_preserves_tool_results_in_summary() -> Non async def test_tool_result_compaction_bidirectional_tracing() -> None: """Summary and originals should link to each other like SummarizationStrategy does.""" messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), _assistant_function_call("call-1"), _tool_result("call-1", "r1"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0) annotate_message_groups(messages) @@ -594,10 +594,10 @@ async def test_tool_result_compaction_bidirectional_tracing() -> None: async def test_tool_result_compaction_summary_has_full_annotations() -> None: """Summary messages inserted by ToolResultCompactionStrategy must have all compaction annotations.""" messages = [ - Message(role="user", text="u"), + Message(role="user", contents=["u"]), _assistant_function_call("c1"), _tool_result("c1", "r1"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0) annotate_message_groups(messages) @@ -617,12 +617,12 @@ async def test_tool_result_compaction_summary_has_full_annotations() -> None: async def test_summarization_strategy_summary_has_full_annotations() -> None: """Summary messages inserted by SummarizationStrategy must have all compaction annotations.""" messages = [ - Message(role="user", text="u1"), - Message(role="assistant", text="a1"), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), - Message(role="user", text="u3"), - Message(role="assistant", text="a3"), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"]), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), + Message(role="user", contents=["u3"]), + Message(role="assistant", contents=["a3"]), ] strategy = SummarizationStrategy(client=_FakeSummarizer(), target_count=2, threshold=0) annotate_message_groups(messages) @@ -647,14 +647,14 @@ async def test_tool_result_compaction_multiple_groups_combined() -> None: separate summary, group 3 stays verbatim. """ messages = [ - Message(role="user", text="Compare weather in London, Paris, and Tokyo"), + Message(role="user", contents=["Compare weather in London, Paris, and Tokyo"]), # Group 1: get_weather for London Message( role="assistant", contents=[Content.from_function_call(call_id="c1", name="get_weather", arguments='{"city":"London"}')], ), _tool_result("c1", '{"temp":12,"condition":"cloudy","wind":"NW 15km/h"}'), - Message(role="assistant", text="London is cloudy at 12°C."), + Message(role="assistant", contents=["London is cloudy at 12°C."]), # Group 2: get_weather for Paris + search_hotels Message( role="assistant", @@ -665,14 +665,14 @@ async def test_tool_result_compaction_multiple_groups_combined() -> None: ), _tool_result("c2", '{"temp":18,"condition":"sunny"}'), _tool_result("c3", "Grand Hotel (€120), Le Petit (€85)"), - Message(role="assistant", text="Paris is sunny at 18°C. Found 2 hotels."), + Message(role="assistant", contents=["Paris is sunny at 18°C. Found 2 hotels."]), # Group 3: get_weather for Tokyo (most recent — should be kept) Message( role="assistant", contents=[Content.from_function_call(call_id="c4", name="get_weather", arguments='{"city":"Tokyo"}')], ), _tool_result("c4", '{"temp":22,"condition":"rainy"}'), - Message(role="assistant", text="Tokyo is rainy at 22°C."), + Message(role="assistant", contents=["Tokyo is rainy at 22°C."]), ] strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) annotate_message_groups(messages) @@ -758,13 +758,13 @@ async def test_compaction_provider_compacts_existing_context_messages() -> None: context = _MockSessionContext() context.context_messages["history"] = [ - Message(role="system", text="sys"), - Message(role="user", text="u1"), - Message(role="assistant", text="a1"), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), - Message(role="user", text="u3"), - Message(role="assistant", text="a3"), + Message(role="system", contents=["sys"]), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"]), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), + Message(role="user", contents=["u3"]), + Message(role="assistant", contents=["a3"]), ] await provider.before_run(agent=None, session=None, context=context, state={}) @@ -796,13 +796,13 @@ async def test_compaction_provider_preserves_messages_from_multiple_sources() -> context = _MockSessionContext() context.context_messages["history"] = [ - Message(role="system", text="sys"), - Message(role="user", text="old_user"), - Message(role="assistant", text="old_assistant"), + Message(role="system", contents=["sys"]), + Message(role="user", contents=["old_user"]), + Message(role="assistant", contents=["old_assistant"]), ] context.context_messages["rag"] = [ - Message(role="user", text="recent_rag_context"), - Message(role="assistant", text="recent_rag_answer"), + Message(role="user", contents=["recent_rag_context"]), + Message(role="assistant", contents=["recent_rag_answer"]), ] await provider.before_run(agent=None, session=None, context=context, state={}) @@ -829,11 +829,11 @@ async def test_compaction_provider_after_run_compacts_stored_history() -> None: session = _MockSession() session.state["in_memory_history"] = { "messages": [ - Message(role="user", text="old question"), - Message(role="assistant", text="old answer"), + Message(role="user", contents=["old question"]), + Message(role="assistant", contents=["old answer"]), _assistant_function_call("c1"), _tool_result("c1", "result"), - Message(role="assistant", text="final answer"), + Message(role="assistant", contents=["final answer"]), ] } @@ -873,11 +873,11 @@ async def test_compaction_provider_both_strategies() -> None: # before_run: compact loaded context context = _MockSessionContext() context.context_messages["history"] = [ - Message(role="system", text="sys"), - Message(role="user", text="u1"), - Message(role="assistant", text="a1"), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), + Message(role="system", contents=["sys"]), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"]), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), ] await provider.before_run(agent=None, session=None, context=context, state={}) assert len(context.get_messages()) == 3 @@ -886,10 +886,10 @@ async def test_compaction_provider_both_strategies() -> None: session = _MockSession() session.state["history"] = { "messages": [ - Message(role="user", text="q"), + Message(role="user", contents=["q"]), _assistant_function_call("c1"), _tool_result("c1", "ok"), - Message(role="assistant", text="done"), + Message(role="assistant", contents=["done"]), ] } await provider.after_run(agent=None, session=session, context=_MockSessionContext(), state={}) @@ -904,8 +904,8 @@ async def test_compaction_provider_none_strategies_are_noop() -> None: context = _MockSessionContext() context.context_messages["history"] = [ - Message(role="user", text="hello"), - Message(role="assistant", text="hi"), + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["hi"]), ] await provider.before_run(agent=None, session=None, context=context, state={}) @@ -924,10 +924,10 @@ async def test_in_memory_history_provider_skip_excluded() -> None: provider = _InMemoryHistoryProvider(skip_excluded=True) state: dict[str, Any] = { "messages": [ - Message(role="user", text="u1"), - Message(role="assistant", text="a1", additional_properties={EXCLUDED_KEY: True}), - Message(role="user", text="u2"), - Message(role="assistant", text="a2"), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"], additional_properties={EXCLUDED_KEY: True}), + Message(role="user", contents=["u2"]), + Message(role="assistant", contents=["a2"]), ] } @@ -944,9 +944,9 @@ async def test_in_memory_history_provider_default_loads_all() -> None: provider = _InMemoryHistoryProvider() state: dict[str, Any] = { "messages": [ - Message(role="user", text="u1"), - Message(role="assistant", text="a1", additional_properties={EXCLUDED_KEY: True}), - Message(role="user", text="u2"), + Message(role="user", contents=["u1"]), + Message(role="assistant", contents=["a1"], additional_properties={EXCLUDED_KEY: True}), + Message(role="user", contents=["u2"]), ] } diff --git a/python/packages/core/tests/core/test_docstrings.py b/python/packages/core/tests/core/test_docstrings.py index ab4b116422..8ec3b2ee7c 100644 --- a/python/packages/core/tests/core/test_docstrings.py +++ b/python/packages/core/tests/core/test_docstrings.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework._docstrings import apply_layered_docstring, build_layered_docstring +from agent_framework._docstrings import apply_layered_docstring, build_layered_docstring, insert_docstring_block # -- Helpers: stub functions with various docstring shapes -- @@ -36,6 +36,14 @@ def _source_no_sections() -> None: """A plain summary with no Google-style sections.""" +def _source_with_attributes() -> None: + """A documented object. + + Attributes: + value: A documented attribute. + """ + + def _source_no_docstring() -> None: pass @@ -141,6 +149,67 @@ def test_build_preserves_multiple_extra_kwargs_order() -> None: assert alpha_idx < beta_idx < gamma_idx +# -- insert_docstring_block tests -- + + +def test_insert_docstring_block_before_args_section() -> None: + result = insert_docstring_block( + _source_with_args_only.__doc__, + block="""\ + .. warning:: Experimental + + This API is experimental. + """, + ) + assert result is not None + lines = result.splitlines() + warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental") + args_index = next(i for i, line in enumerate(lines) if line == "Args:") + assert warning_index < args_index + + +def test_insert_docstring_block_before_attributes_section() -> None: + result = insert_docstring_block( + _source_with_attributes.__doc__, + block="""\ + .. warning:: Experimental + + This API is experimental. + """, + ) + assert result is not None + lines = result.splitlines() + warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental") + attributes_index = next(i for i, line in enumerate(lines) if line == "Attributes:") + assert warning_index < attributes_index + + +def test_insert_docstring_block_appends_when_no_sections() -> None: + result = insert_docstring_block( + _source_no_sections.__doc__, + block="""\ + .. note:: Release candidate + + This API is nearly final. + """, + ) + assert result is not None + assert result.endswith("This API is nearly final.") + assert ".. note:: Release candidate" in result + + +def test_insert_docstring_block_returns_block_for_missing_docstring() -> None: + result = insert_docstring_block( + _source_no_docstring.__doc__, + block="""\ + .. warning:: Experimental + + This API is experimental. + """, + ) + assert result == ".. warning:: Experimental\n\n This API is experimental." + + # -- apply_layered_docstring tests -- diff --git a/python/packages/core/tests/core/test_embedding_client.py b/python/packages/core/tests/core/test_embedding_client.py index 1c49c1d012..38a7b74bdc 100644 --- a/python/packages/core/tests/core/test_embedding_client.py +++ b/python/packages/core/tests/core/test_embedding_client.py @@ -25,7 +25,7 @@ class MockEmbeddingClient(BaseEmbeddingClient): options: EmbeddingGenerationOptions | None = None, ) -> GeneratedEmbeddings[list[float]]: return GeneratedEmbeddings( - [Embedding(vector=[0.1, 0.2, 0.3], model_id="mock-model") for _ in values], + [Embedding(vector=[0.1, 0.2, 0.3], model="mock-model") for _ in values], usage={"prompt_tokens": len(values), "total_tokens": len(values)}, ) @@ -38,12 +38,12 @@ async def test_base_get_embeddings() -> None: result = await client.get_embeddings(["hello", "world"]) assert len(result) == 2 assert result[0].vector == [0.1, 0.2, 0.3] - assert result[0].model_id == "mock-model" + assert result[0].model == "mock-model" async def test_base_get_embeddings_with_options() -> None: client = MockEmbeddingClient() - options: EmbeddingGenerationOptions = {"model_id": "test", "dimensions": 3} + options: EmbeddingGenerationOptions = {"model": "test", "dimensions": 3} result = await client.get_embeddings(["hello"], options=options) assert len(result) == 1 diff --git a/python/packages/core/tests/core/test_embedding_types.py b/python/packages/core/tests/core/test_embedding_types.py index 0d6db6b27e..95c5ede612 100644 --- a/python/packages/core/tests/core/test_embedding_types.py +++ b/python/packages/core/tests/core/test_embedding_types.py @@ -12,7 +12,7 @@ from agent_framework import Embedding, EmbeddingGenerationOptions, GeneratedEmbe def test_embedding_basic_construction() -> None: embedding = Embedding(vector=[0.1, 0.2, 0.3]) assert embedding.vector == [0.1, 0.2, 0.3] - assert embedding.model_id is None + assert embedding.model is None assert embedding.created_at is None assert embedding.additional_properties == {} @@ -21,11 +21,11 @@ def test_embedding_construction_with_metadata() -> None: now = datetime.now() embedding = Embedding( vector=[0.1, 0.2], - model_id="text-embedding-3-small", + model="text-embedding-3-small", created_at=now, additional_properties={"key": "value"}, ) - assert embedding.model_id == "text-embedding-3-small" + assert embedding.model == "text-embedding-3-small" assert embedding.created_at == now assert embedding.additional_properties == {"key": "value"} @@ -96,7 +96,7 @@ def test_generated_construction_with_usage() -> None: [ Embedding( vector=[0.1], - model_id="test-model", + model="test-model", ) ], usage=usage, @@ -113,13 +113,13 @@ def test_generated_construction_with_additional_properties() -> None: def test_generated_construction_with_options() -> None: - opts: EmbeddingGenerationOptions = {"model_id": "text-embedding-3-small", "dimensions": 256} + opts: EmbeddingGenerationOptions = {"model": "text-embedding-3-small", "dimensions": 256} embeddings = GeneratedEmbeddings( [Embedding(vector=[0.1])], options=opts, ) assert embeddings.options is not None - assert embeddings.options["model_id"] == "text-embedding-3-small" + assert embeddings.options["model"] == "text-embedding-3-small" assert embeddings.options["dimensions"] == 256 @@ -160,12 +160,12 @@ def test_generated_none_embeddings_creates_empty_list() -> None: def test_options_empty() -> None: options: EmbeddingGenerationOptions = {} - assert "model_id" not in options + assert "model" not in options -def test_options_with_model_id() -> None: - options: EmbeddingGenerationOptions = {"model_id": "text-embedding-3-small"} - assert options["model_id"] == "text-embedding-3-small" +def test_options_with_model() -> None: + options: EmbeddingGenerationOptions = {"model": "text-embedding-3-small"} + assert options["model"] == "text-embedding-3-small" def test_options_with_dimensions() -> None: @@ -175,8 +175,8 @@ def test_options_with_dimensions() -> None: def test_options_with_all_fields() -> None: options: EmbeddingGenerationOptions = { - "model_id": "text-embedding-3-small", + "model": "text-embedding-3-small", "dimensions": 1536, } - assert options["model_id"] == "text-embedding-3-small" + assert options["model"] == "text-embedding-3-small" assert options["dimensions"] == 1536 diff --git a/python/packages/core/tests/core/test_feature_stage.py b/python/packages/core/tests/core/test_feature_stage.py new file mode 100644 index 0000000000..3b5f495e33 --- /dev/null +++ b/python/packages/core/tests/core/test_feature_stage.py @@ -0,0 +1,427 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import inspect +import warnings +from enum import Enum +from typing import Protocol, runtime_checkable + +import pytest + +from agent_framework import ExperimentalFeature as PublicExperimentalFeature +from agent_framework import ReleaseCandidateFeature as PublicReleaseCandidateFeature +from agent_framework._feature_stage import ( + _WARNED_FEATURES, + ExperimentalWarning, + _feature_stage, + experimental, + release_candidate, +) +from agent_framework._feature_stage import ( + ExperimentalFeature as InternalExperimentalFeature, +) +from agent_framework._feature_stage import ( + ReleaseCandidateFeature as InternalReleaseCandidateFeature, +) + + +class AlternateExperimentalFeature(str, Enum): + EXPERIMENTAL_FEATURE = "EXPERIMENTAL_FEATURE" + SHARED_FEATURE = "SHARED_EXPERIMENTAL_FEATURE" + ALTERNATE_FEATURE = "ALTERNATE_EXPERIMENTAL_FEATURE" + + +class InvalidStageFeature(str, Enum): + LOWERCASE = "skills" + + +class NonStringFeature(Enum): + INTEGER = 1 + + +class HelperReleaseCandidateFeature(str, Enum): + RC_FEATURE = "RC_FEATURE" + + +@pytest.fixture(autouse=True) +def clear_feature_warning_state() -> None: + _WARNED_FEATURES.clear() + yield + _WARNED_FEATURES.clear() + + +def test_feature_enums_are_exposed_from_root() -> None: + assert PublicExperimentalFeature is InternalExperimentalFeature + assert PublicReleaseCandidateFeature is InternalReleaseCandidateFeature + + +def test_experimental_decorator_accepts_feature_enum() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type] + def skill_function() -> None: + pass + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + skill_function() + + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message) + assert "skill_function" in str(caught[0].message) + assert skill_function.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value + + +def test_experimental_function_warns_on_call_and_not_on_definition() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type] + def my_function(value: int) -> int: + """Double the input. + + Args: + value: Value to double. + + Returns: + The doubled value. + """ + return value * 2 + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + assert my_function(3) == 6 + assert my_function(4) == 8 + + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message) + assert "my_function" in str(caught[0].message) + assert my_function.__feature_stage__ == "experimental" + assert my_function.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value + assert my_function.__doc__ is not None + lines = my_function.__doc__.splitlines() + warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental") + args_index = next(i for i, line in enumerate(lines) if line == "Args:") + assert warning_index < args_index + + +def test_experimental_class_warns_on_instantiation_and_not_on_definition() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type] + class ExperimentalClass: + """An experimental class. + + Args: + value: Value to store. + """ + + def __init__(self, value: int) -> None: + self.value = value + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + instantiation_line = inspect.currentframe().f_lineno + 1 + instance = ExperimentalClass(4) + second_instance = ExperimentalClass(5) + + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message) + assert "ExperimentalClass" in str(caught[0].message) + assert caught[0].filename == __file__ + assert caught[0].lineno == instantiation_line + assert instance.value == 4 + assert second_instance.value == 5 + assert ExperimentalClass.__feature_stage__ == "experimental" + assert ExperimentalClass.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value + + +def test_experimental_runtime_checkable_protocol_keeps_protocol_runtime_checks() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @runtime_checkable + @experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type] + class ExampleProtocol(Protocol): + """A protocol used for runtime checks. + + Returns: + Nothing. + """ + + def __call__(self, value: int) -> int: ... + + assert not caught + + def implementation(value: int) -> int: + return value + + assert isinstance(implementation, ExampleProtocol) + assert ExampleProtocol.__doc__ is not None + assert ".. warning:: Experimental" in ExampleProtocol.__doc__ + assert getattr(ExampleProtocol, "__feature_stage__", None) is None + assert getattr(ExampleProtocol, "__feature_id__", None) is None + + +def test_experimental_warning_is_emitted_once_per_feature() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @experimental(feature_id=AlternateExperimentalFeature.SHARED_FEATURE) # type: ignore[arg-type] + def first() -> None: + pass + + @experimental(feature_id=AlternateExperimentalFeature.SHARED_FEATURE) # type: ignore[arg-type] + class Second: + pass + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + first() + Second() + + assert first is not None + assert Second is not None + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.SHARED_FEATURE.value}]" in str(caught[0].message) + assert "first" in str(caught[0].message) + + +def test_release_candidate_internal_helper_adds_metadata_without_runtime_warning() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @_feature_stage( + stage="release_candidate", + feature_id=HelperReleaseCandidateFeature.RC_FEATURE, + docstring_block="""\ +.. note:: Release candidate + + This API is in release-candidate stage and may receive + minor refinements before it is considered generally available. +""", + warning_category=None, + ) + class ReleaseCandidateClass: + """A release-candidate class. + + Args: + value: Value to store. + """ + + def __init__(self, value: int) -> None: + self.value = value + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + instance = ReleaseCandidateClass(5) + + assert instance.value == 5 + assert not caught + assert ReleaseCandidateClass.__feature_stage__ == "release_candidate" + assert ReleaseCandidateClass.__feature_id__ == HelperReleaseCandidateFeature.RC_FEATURE.value + assert ReleaseCandidateClass.__doc__ is not None + assert ".. note:: Release candidate" in ReleaseCandidateClass.__doc__ + + +def test_experimental_property_warns_on_access_and_not_on_definition() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + class Example: + @property + @experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type] + def value(self) -> int: + """Return the value. + + Returns: + The stored value. + """ + return 1 + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + assert Example().value == 1 + assert Example().value == 1 + + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message) + assert "Example.value" in str(caught[0].message) + assert Example.value.__doc__ is not None + lines = Example.value.__doc__.splitlines() + warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental") + returns_index = next(i for i, line in enumerate(lines) if line == "Returns:") + assert warning_index < returns_index + + +def test_experimental_staticmethod_warns_when_decorator_wraps_descriptor() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + class Example: + @experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type] + @staticmethod + def value() -> int: + """Return the value. + + Returns: + The stored value. + """ + return 1 + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + assert Example.value() == 1 + assert Example.value() == 1 + + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message) + assert "Example.value" in str(caught[0].message) + assert Example.value.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value + assert Example.value.__doc__ is not None + lines = Example.value.__doc__.splitlines() + warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental") + returns_index = next(i for i, line in enumerate(lines) if line == "Returns:") + assert warning_index < returns_index + + +def test_experimental_classmethod_warns_when_decorator_wraps_descriptor() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + class Example: + @experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type] + @classmethod + def value(cls) -> int: + """Return the value. + + Returns: + The stored value. + """ + return 1 + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + assert Example.value() == 1 + assert Example.value() == 1 + + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message) + assert "Example.value" in str(caught[0].message) + assert Example.value.__func__.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value + assert Example.value.__doc__ is not None + lines = Example.value.__doc__.splitlines() + warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental") + returns_index = next(i for i, line in enumerate(lines) if line == "Returns:") + assert warning_index < returns_index + + +def test_feature_id_allows_lowercase_values() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @_feature_stage( + stage="experimental", + feature_id=InvalidStageFeature.LOWERCASE, + docstring_block=".. warning:: Experimental", + warning_category=ExperimentalWarning, + ) + def lowercase_feature() -> None: + pass + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + lowercase_feature() + + assert len(caught) == 1 + assert "[skills]" in str(caught[0].message) + assert "lowercase_feature" in str(caught[0].message) + assert lowercase_feature.__feature_id__ == "skills" + + +def test_experimental_decorator_allows_string_feature_id_at_runtime() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @experimental(feature_id="STRING_FEATURE") # type: ignore[arg-type] + def skill_function() -> None: + pass + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + skill_function() + + assert len(caught) == 1 + assert "[STRING_FEATURE]" in str(caught[0].message) + assert "skill_function" in str(caught[0].message) + assert skill_function.__feature_id__ == "STRING_FEATURE" + + +def test_experimental_decorator_allows_other_enum_values_at_runtime() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @experimental(feature_id=AlternateExperimentalFeature.ALTERNATE_FEATURE) # type: ignore[arg-type] + def my_function() -> None: + pass + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + my_function() + + assert len(caught) == 1 + assert f"[{AlternateExperimentalFeature.ALTERNATE_FEATURE.value}]" in str(caught[0].message) + assert "my_function" in str(caught[0].message) + assert my_function.__feature_id__ == AlternateExperimentalFeature.ALTERNATE_FEATURE.value + + +def test_release_candidate_decorator_allows_string_feature_id_at_runtime() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @release_candidate(feature_id="RC_FEATURE") # type: ignore[arg-type] + class ReleaseCandidateClass: + """A release-candidate class.""" + + assert not caught + assert ReleaseCandidateClass.__feature_stage__ == "release_candidate" + assert ReleaseCandidateClass.__feature_id__ == "RC_FEATURE" + + +def test_feature_id_stringifies_non_string_enum_values() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @_feature_stage( + stage="experimental", + feature_id=NonStringFeature.INTEGER, + docstring_block=".. warning:: Experimental", + warning_category=ExperimentalWarning, + ) + def numeric_feature() -> None: + pass + + assert not caught + + with warnings.catch_warnings(record=True) as caught: + numeric_feature() + + assert len(caught) == 1 + assert "[1]" in str(caught[0].message) + assert "numeric_feature" in str(caught[0].message) + assert numeric_feature.__feature_id__ == "1" diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 96bec7d547..fe9a814572 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -55,10 +55,10 @@ async def test_base_client_with_function_calling(chat_client_base: SupportsChatG ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [ai_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]} ) assert exec_counter == 1 assert len(response.messages) == 3 @@ -93,7 +93,7 @@ async def test_base_client_with_function_calling_string_input(chat_client_base: ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]}) @@ -132,10 +132,10 @@ async def test_base_client_with_function_calling_resets(chat_client_base: Suppor ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [ai_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]} ) assert exec_counter == 2 assert len(response.messages) == 5 @@ -193,11 +193,11 @@ async def test_function_loop_applies_compaction_projection_each_model_call(chat_ ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [ai_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]} ) assert len(captured_roles) >= 2 @@ -256,11 +256,11 @@ async def test_function_loop_token_budget_strategy_caps_tokens_each_iteration( ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello " * 160)], + [Message(role="user", contents=["hello " * 160])], options={"tool_choice": "auto", "tools": [ai_func]}, ) @@ -349,13 +349,13 @@ async def test_base_client_executes_function_calls_across_multiple_response_mess conversation_id="conv_after_first_call", ), ChatResponse( - messages=Message(role="assistant", text="done"), + messages=Message(role="assistant", contents=["done"]), conversation_id="conv_after_second_call", ), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func], "conversation_id": "conv_initial"}, ) @@ -392,7 +392,7 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: Suppo ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] agent = Agent(client=chat_client_base, tools=[ai_func]) @@ -449,7 +449,7 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Sup ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] agent = Agent(client=chat_client_base, tools=[ai_func]) @@ -569,7 +569,7 @@ async def test_function_invocation_scenarios( # Single function call content func_call = Content.from_function_call(call_id="1", name=function_name, arguments='{"arg1": "value1"}') - completion = Message(role="assistant", text="done") + completion = Message(role="assistant", contents=["done"]) chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=[func_call]))] + ( [] if approval_required else [ChatResponse(messages=completion)] @@ -618,12 +618,12 @@ async def test_function_invocation_scenarios( options["conversation_id"] = conversation_id if not streaming: - response = await chat_client_base.get_response([Message(role="user", text="hello")], options=options) + response = await chat_client_base.get_response([Message(role="user", contents=["hello"])], options=options) messages = response.messages else: updates = [] async for update in chat_client_base.get_response( - [Message(role="user", text="hello")], options=options, stream=True + [Message(role="user", contents=["hello"])], options=options, stream=True ): updates.append(update) messages = updates @@ -729,7 +729,7 @@ async def test_rejected_approval(chat_client_base: SupportsChatGetResponse): ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Get the response with approval requests @@ -850,7 +850,7 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Su ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Get approval request @@ -860,7 +860,7 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Su # Store messages (like a thread would) persisted_messages = [ - Message(role="user", text="hello"), + Message(role="user", contents=["hello"]), *response1.messages, ] @@ -899,7 +899,7 @@ async def test_no_duplicate_function_calls_after_approval_processing(chat_client ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response1 = await chat_client_base.get_response( @@ -943,7 +943,7 @@ async def test_rejection_result_uses_function_call_id(chat_client_base: Supports ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response1 = await chat_client_base.get_response( @@ -1000,14 +1000,14 @@ async def test_max_iterations_limit(chat_client_base: SupportsChatGetResponse): ) ), # Failsafe response when tool_choice is set to "none" - ChatResponse(messages=Message(role="assistant", text="giving up on tools")), + ChatResponse(messages=Message(role="assistant", contents=["giving up on tools"])), ] # Set max_iterations to 1 in additional_properties chat_client_base.function_invocation_configuration["max_iterations"] = 1 response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [ai_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]} ) # With max_iterations=1, we should: @@ -1061,7 +1061,7 @@ async def test_max_iterations_no_orphaned_function_calls(chat_client_base: Suppo chat_client_base.function_invocation_configuration["max_iterations"] = 2 response = await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]}, ) @@ -1111,13 +1111,13 @@ async def test_max_iterations_makes_final_toolchoice_none_call(chat_client_base: ) ), # This response should be reached via failsafe (tool_choice="none") - ChatResponse(messages=Message(role="assistant", text="Final answer after giving up on tools.")), + ChatResponse(messages=Message(role="assistant", contents=["Final answer after giving up on tools."])), ] chat_client_base.function_invocation_configuration["max_iterations"] = 1 response = await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]}, ) @@ -1170,13 +1170,13 @@ async def test_max_iterations_preserves_all_fcc_messages(chat_client_base: Suppo ], ) ), - ChatResponse(messages=Message(role="assistant", text="Done")), + ChatResponse(messages=Message(role="assistant", contents=["Done"])), ] chat_client_base.function_invocation_configuration["max_iterations"] = 2 response = await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]}, ) @@ -1301,14 +1301,14 @@ async def test_max_function_calls_limits_parallel_invocations(chat_client_base: ) ), # Final response after tool_choice="none" is forced - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Allow many iterations but cap total function calls at 5 chat_client_base.function_invocation_configuration["max_function_calls"] = 5 response = await chat_client_base.get_response( - [Message(role="user", text="search")], options={"tool_choice": "auto", "tools": [search_func]} + [Message(role="user", contents=["search"])], options={"tool_choice": "auto", "tools": [search_func]} ) # First iteration executes 3 calls (total=3, under limit). @@ -1355,13 +1355,13 @@ async def test_max_function_calls_single_calls_per_iteration(chat_client_base: S ) ), # After limit is reached - ChatResponse(messages=Message(role="assistant", text="all done")), + ChatResponse(messages=Message(role="assistant", contents=["all done"])), ] chat_client_base.function_invocation_configuration["max_function_calls"] = 2 response = await chat_client_base.get_response( - [Message(role="user", text="look up keys")], options={"tool_choice": "auto", "tools": [lookup_func]} + [Message(role="user", contents=["look up keys"])], options={"tool_choice": "auto", "tools": [lookup_func]} ) # 2 single calls executed, then limit reached, tool_choice="none" forced @@ -1390,13 +1390,13 @@ async def test_max_function_calls_none_means_unlimited(chat_client_base: Support ) ) for i in range(5) - ] + [ChatResponse(messages=Message(role="assistant", text="finished"))] + ] + [ChatResponse(messages=Message(role="assistant", contents=["finished"]))] # Explicitly set to None (default) — should not limit chat_client_base.function_invocation_configuration["max_function_calls"] = None response = await chat_client_base.get_response( - [Message(role="user", text="do things")], options={"tool_choice": "auto", "tools": [do_thing_func]} + [Message(role="user", contents=["do things"])], options={"tool_choice": "auto", "tools": [do_thing_func]} ) assert exec_counter == 5 @@ -1414,14 +1414,14 @@ async def test_function_invocation_config_enabled_false(chat_client_base: Suppor return f"Processed {arg1}" chat_client_base.run_responses = [ - ChatResponse(messages=Message(role="assistant", text="response without function calling")), + ChatResponse(messages=Message(role="assistant", contents=["response without function calling"])), ] # Disable function invocation chat_client_base.function_invocation_configuration["enabled"] = False response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [ai_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]} ) # Function should not be executed - when enabled=False, the loop doesn't run @@ -1447,12 +1447,12 @@ async def test_function_invocation_config_enabled_false_preserves_invocation_kwa chat_client_base.chat_middleware = [capture_middleware] chat_client_base.run_responses = [ - ChatResponse(messages=Message(role="assistant", text="response without function calling")), + ChatResponse(messages=Message(role="assistant", contents=["response without function calling"])), ] chat_client_base.function_invocation_configuration["enabled"] = False await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [ai_func]}, function_invocation_kwargs={"tool_request_id": "tool-123"}, ) @@ -1502,14 +1502,14 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas ], ) ), - ChatResponse(messages=Message(role="assistant", text="final response")), + ChatResponse(messages=Message(role="assistant", contents=["final response"])), ] # Set max_consecutive_errors to 2 chat_client_base.function_invocation_configuration["max_consecutive_errors_per_request"] = 2 response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [error_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [error_func]} ) # Should stop after 2 consecutive errors and force a non-tool response @@ -1552,7 +1552,7 @@ async def test_function_invocation_stop_clears_conversation_id_non_stream(chat_c session_stub = type("SessionStub", (), {"service_session_id": "resp_seed"})() response = await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [error_func]}, client_kwargs={"session": session_stub}, ) @@ -1579,14 +1579,14 @@ async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_ ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set terminate_on_unknown_calls to False (default) chat_client_base.function_invocation_configuration["terminate_on_unknown_calls"] = False response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [known_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [known_func]} ) # Should have a result message indicating the tool wasn't found @@ -1624,7 +1624,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_c # Should raise an exception when encountering an unknown function with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'): await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [known_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [known_func]} ) assert exec_counter == 0 @@ -1656,7 +1656,7 @@ async def test_function_invocation_config_additional_tools(chat_client_base: Sup ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Add hidden_func to additional_tools @@ -1664,7 +1664,7 @@ async def test_function_invocation_config_additional_tools(chat_client_base: Sup # Only pass visible_func in the tools parameter response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [visible_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [visible_func]} ) # Additional tools are treated as declaration_only, so not executed @@ -1697,14 +1697,14 @@ async def test_function_invocation_config_include_detailed_errors_false(chat_cli ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set include_detailed_errors to False (default) chat_client_base.function_invocation_configuration["include_detailed_errors"] = False response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [error_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [error_func]} ) # Should have a generic error message @@ -1733,14 +1733,14 @@ async def test_function_invocation_config_include_detailed_errors_true(chat_clie ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set include_detailed_errors to True chat_client_base.function_invocation_configuration["include_detailed_errors"] = True response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [error_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [error_func]} ) # Should have detailed error message @@ -1832,14 +1832,14 @@ async def test_argument_validation_error_with_detailed_errors(chat_client_base: ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set include_detailed_errors to True chat_client_base.function_invocation_configuration["include_detailed_errors"] = True response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [typed_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [typed_func]} ) # Should have detailed validation error @@ -1868,14 +1868,14 @@ async def test_argument_validation_error_without_detailed_errors(chat_client_bas ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set include_detailed_errors to False (default) chat_client_base.function_invocation_configuration["include_detailed_errors"] = False response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [typed_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [typed_func]} ) # Should have generic validation error @@ -1906,7 +1906,7 @@ async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetRe ) chat_client_base.run_responses = [ - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Send the approval response @@ -1947,13 +1947,13 @@ async def test_hosted_mcp_approval_response_passthrough(chat_client_base: Suppor # The second call (after approval) should return a final response chat_client_base.run_responses = [ - ChatResponse(messages=Message(role="assistant", text="Here are the docs results.")), + ChatResponse(messages=Message(role="assistant", contents=["Here are the docs results."])), ] # Build message list mimicking handle_approvals_without_session: # [original query, assistant with approval_request, user with approval_response] messages = [ - Message(role="user", text="Search docs for azure storage"), + Message(role="user", contents=["Search docs for azure storage"]), Message(role="assistant", contents=[mcp_approval_request]), Message(role="user", contents=[mcp_approval_response]), ] @@ -2034,7 +2034,7 @@ async def test_mixed_local_and_hosted_approval_flow(chat_client_base: SupportsCh chat_client_base.run_responses = [ ChatResponse(messages=Message(role="assistant", contents=[local_fc])), # After local approval + hosted approval, the final response - ChatResponse(messages=Message(role="assistant", text="Done with both tools.")), + ChatResponse(messages=Message(role="assistant", contents=["Done with both tools."])), ] # User approves the local function call @@ -2045,7 +2045,7 @@ async def test_mixed_local_and_hosted_approval_flow(chat_client_base: SupportsCh mcp_approval_response = mcp_approval_request.to_function_approval_response(approved=True) messages = [ - Message(role="user", text="Search docs and run local"), + Message(role="user", contents=["Search docs and run local"]), Message(role="assistant", contents=[local_fc, mcp_approval_request]), Message(role="user", contents=[local_approval_response]), Message(role="user", contents=[mcp_approval_response]), @@ -2080,12 +2080,12 @@ async def test_unapproved_tool_execution_raises_exception(chat_client_base: Supp ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Get approval request response1 = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [test_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [test_func]} ) approval_req = [c for c in response1.messages[0].contents if c.type == "function_approval_request"][0] @@ -2137,7 +2137,7 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl contents=[Content.from_function_call(call_id="1", name="error_func", arguments='{"arg1": "value1"}')], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set include_detailed_errors to False (default) @@ -2145,7 +2145,7 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl # Get approval request response1 = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [error_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [error_func]} ) approval_req = [c for c in response1.messages[0].contents if c.type == "function_approval_request"][0] @@ -2202,7 +2202,7 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien contents=[Content.from_function_call(call_id="1", name="error_func", arguments='{"arg1": "value1"}')], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set include_detailed_errors to True @@ -2210,7 +2210,7 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien # Get approval request response1 = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [error_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [error_func]} ) approval_req = [c for c in response1.messages[0].contents if c.type == "function_approval_request"][0] @@ -2267,7 +2267,7 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Su ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Set include_detailed_errors to True to see validation details @@ -2275,7 +2275,7 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Su # Get approval request response1 = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [typed_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [typed_func]} ) approval_req = [c for c in response1.messages[0].contents if c.type == "function_approval_request"][0] @@ -2328,12 +2328,12 @@ async def test_approved_function_call_successful_execution(chat_client_base: Sup contents=[Content.from_function_call(call_id="1", name="success_func", arguments='{"arg1": "value1"}')], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Get approval request response1 = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [success_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [success_func]} ) approval_req = [c for c in response1.messages[0].contents if c.type == "function_approval_request"][0] @@ -2391,7 +2391,7 @@ async def test_declaration_only_tool(chat_client_base: SupportsChatGetResponse): ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( @@ -2447,11 +2447,11 @@ async def test_multiple_function_calls_parallel_execution(chat_client_base: Supp ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [func1, func2]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [func1, func2]} ) # Both functions should have been executed @@ -2485,12 +2485,12 @@ async def test_callable_function_converted_to_tool(chat_client_base: SupportsCha ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] # Pass plain function (will be auto-converted) response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [plain_function]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [plain_function]} ) # Function should be executed @@ -2518,13 +2518,13 @@ async def test_conversation_id_handling(chat_client_base: SupportsChatGetRespons conversation_id="conv_123", # Simulate service-side thread ), ChatResponse( - messages=Message(role="assistant", text="done"), + messages=Message(role="assistant", contents=["done"]), conversation_id="conv_123", ), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [test_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [test_func]} ) # Should have executed the function @@ -2549,11 +2549,11 @@ async def test_function_result_appended_to_existing_assistant_message(chat_clien ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [test_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [test_func]} ) # Should have messages with both function call and function result @@ -2596,11 +2596,11 @@ async def test_error_recovery_resets_counter(chat_client_base: SupportsChatGetRe ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [sometimes_fails]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [sometimes_fails]} ) # Should have both an error and a success @@ -2912,7 +2912,7 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_t # Should raise an exception when encountering an unknown function with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'): async for _ in chat_client_base.get_response( - [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [known_func]} + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [known_func]} ): pass @@ -3248,7 +3248,7 @@ async def test_terminate_loop_single_function_call(chat_client_base: SupportsCha ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( @@ -3314,7 +3314,7 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client ], ) ), - ChatResponse(messages=Message(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", contents=["done"])), ] response = await chat_client_base.get_response( @@ -3444,7 +3444,7 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): async def _get() -> ChatResponse: self.call_count += 1 if not self.run_responses: - return ChatResponse(messages=Message(role="assistant", text="done")) + return ChatResponse(messages=Message(role="assistant", contents=["done"])) return self.run_responses.pop(0) return _get() @@ -3491,7 +3491,7 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): conversation_id="conv_after_first_call", ), ChatResponse( - messages=Message(role="assistant", text="done"), + messages=Message(role="assistant", contents=["done"]), conversation_id="conv_after_second_call", ), ] @@ -3706,7 +3706,7 @@ async def test_user_input_request_propagates_through_as_tool(chat_client_base: S ] response = await chat_client_base.get_response( - [Message(role="user", text="delegate this")], + [Message(role="user", contents=["delegate this"])], options={"tool_choice": "auto", "tools": [delegate_tool]}, ) @@ -3755,7 +3755,7 @@ async def test_user_input_request_multiple_contents_propagate(chat_client_base: ] response = await chat_client_base.get_response( - [Message(role="user", text="do something")], + [Message(role="user", contents=["do something"])], options={"tool_choice": "auto", "tools": [multi_request]}, ) @@ -3792,11 +3792,11 @@ async def test_user_input_request_empty_contents_returns_fallback(chat_client_ba ], ) ), - ChatResponse(messages=Message(role="assistant", text="handled")), + ChatResponse(messages=Message(role="assistant", contents=["handled"])), ] response = await chat_client_base.get_response( - [Message(role="user", text="do something")], + [Message(role="user", contents=["do something"])], options={"tool_choice": "auto", "tools": [empty_request]}, ) diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 09c036c704..01cf1717bd 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1727,7 +1727,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): ], ) ] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1778,7 +1778,7 @@ async def test_mcp_tool_sampling_callback_no_response_and_successful_message_cre tool.client.get_response.return_value = Mock( messages=[Message(role="assistant", contents=[Content.from_text("Hello")])], - model_id="test-model", + model="test-model", ) success = await tool.sampling_callback(Mock(), params) @@ -1808,7 +1808,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1843,7 +1843,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1889,7 +1889,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1924,7 +1924,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1959,7 +1959,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1994,7 +1994,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options( mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -2035,7 +2035,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -2072,7 +2072,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -3804,4 +3804,377 @@ async def test_mcp_tool_call_tool_otel_meta(use_span, expect_traceparent, span_e assert meta is None +async def test_mcp_streamable_http_tool_hook_not_duplicated_on_repeated_get_mcp_client(): + """Test that calling get_mcp_client multiple times does not accumulate duplicate hooks.""" + tool = MCPStreamableHTTPTool( + name="test", + url="http://example.com/mcp", + header_provider=lambda kw: {"X-Token": kw.get("token", "")}, + ) + + try: + with patch("agent_framework._mcp.streamable_http_client"): + tool.get_mcp_client() + tool.get_mcp_client() + tool.get_mcp_client() + + assert tool._httpx_client is not None + hooks = tool._httpx_client.event_hooks.get("request", []) + assert len(hooks) == 1, f"Expected exactly one hook, got {len(hooks)}" + finally: + if getattr(tool, "_httpx_client", None) is not None: + await tool._httpx_client.aclose() + + +# endregion + + +# region: MCPStreamableHTTPTool header_provider + + +async def test_mcp_streamable_http_tool_header_provider_injects_headers(): + """Test that header_provider integrates with call_tool via runtime kwargs. + + When header_provider is configured, runtime kwargs from FunctionInvocationContext + are passed to the provider and the MCP session.call_tool is invoked successfully. + """ + + class _TestServer(MCPStreamableHTTPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Says hello", + inputSchema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ] + ) + ) + self.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) + ) + self.session.send_ping = AsyncMock() + self.is_connected = True + + def get_mcp_client(self): + return None + + def provider(kwargs): + return {"X-Some-Token": kwargs.get("some_token", "")} + + server = _TestServer( + name="test", + url="http://example.com/mcp", + header_provider=provider, + ) + async with server: + await server.load_tools() + + # Simulate the runtime kwargs that flow from FunctionInvocationContext.kwargs + await server.call_tool("greet", name="Alice", some_token="my-secret") + + # Verify the MCP session.call_tool was called + server.session.call_tool.assert_called_once() + + +async def test_mcp_streamable_http_tool_header_provider_sets_contextvar(): + """Test that call_tool sets the contextvar with headers from header_provider.""" + from agent_framework._mcp import _mcp_call_headers + + observed_headers: list[dict[str, str]] = [] + original_call_tool = MCPTool.call_tool + + async def spy_call_tool(self, tool_name, **kwargs): + # Capture the contextvar value during the super call + try: + observed_headers.append(_mcp_call_headers.get()) + except LookupError: + observed_headers.append({}) + return await original_call_tool(self, tool_name, **kwargs) + + class _TestServer(MCPStreamableHTTPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Says hello", + inputSchema={"type": "object", "properties": {"name": {"type": "string"}}}, + ) + ] + ) + ) + self.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) + ) + self.session.send_ping = AsyncMock() + self.is_connected = True + + def get_mcp_client(self): + return None + + server = _TestServer( + name="test", + url="http://example.com/mcp", + header_provider=lambda kw: {"X-Auth": kw.get("auth_token", "")}, + ) + async with server: + await server.load_tools() + + with patch.object(MCPTool, "call_tool", spy_call_tool): + await server.call_tool("greet", name="Alice", auth_token="bearer-xyz") + + assert len(observed_headers) == 1 + assert observed_headers[0] == {"X-Auth": "bearer-xyz"} + + +async def test_mcp_streamable_http_tool_header_provider_contextvar_reset_after_call(): + """Test that the contextvar is properly reset after call_tool completes.""" + from agent_framework._mcp import _mcp_call_headers + + class _TestServer(MCPStreamableHTTPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Says hello", + inputSchema={"type": "object", "properties": {"name": {"type": "string"}}}, + ) + ] + ) + ) + self.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) + ) + self.session.send_ping = AsyncMock() + self.is_connected = True + + def get_mcp_client(self): + return None + + server = _TestServer( + name="test", + url="http://example.com/mcp", + header_provider=lambda kw: {"X-Token": kw.get("token", "")}, + ) + async with server: + await server.load_tools() + await server.call_tool("greet", name="Alice", token="secret") + + # After call_tool, the contextvar should be unset (reset to no value) + with pytest.raises(LookupError): + _mcp_call_headers.get() + + +async def test_mcp_streamable_http_tool_without_header_provider(): + """Test that call_tool works normally when no header_provider is configured.""" + + class _TestServer(MCPStreamableHTTPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Says hello", + inputSchema={"type": "object", "properties": {"name": {"type": "string"}}}, + ) + ] + ) + ) + self.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) + ) + self.session.send_ping = AsyncMock() + self.is_connected = True + + def get_mcp_client(self): + return None + + server = _TestServer( + name="test", + url="http://example.com/mcp", + ) + async with server: + await server.load_tools() + await server.call_tool("greet", name="Alice") + server.session.call_tool.assert_called_once() + + # Without header_provider, call_tool should delegate directly to MCPTool + assert server._header_provider is None + + +async def test_mcp_streamable_http_tool_header_provider_with_httpx_event_hook(): + """Test that the httpx event hook injects headers from the contextvar.""" + import httpx + + from agent_framework._mcp import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT, _mcp_call_headers + + tool = MCPStreamableHTTPTool( + name="test", + url="http://example.com/mcp", + header_provider=lambda kw: {"X-Custom": kw.get("custom", "")}, + ) + + try: + with patch("agent_framework._mcp.streamable_http_client"): + # Trigger get_mcp_client to set up the event hook + tool.get_mcp_client() + + # The tool should have created an httpx client with the event hook + assert tool._httpx_client is not None + assert tool._httpx_client.follow_redirects is True + assert tool._httpx_client.timeout.connect == MCP_DEFAULT_TIMEOUT + assert tool._httpx_client.timeout.read == MCP_DEFAULT_SSE_READ_TIMEOUT + hooks = tool._httpx_client.event_hooks.get("request", []) + assert len(hooks) == 1, "Expected one request event hook" + + # Simulate what happens during a call_tool: contextvar is set + token = _mcp_call_headers.set({"X-Custom": "test-value"}) + try: + request = httpx.Request("POST", "http://example.com/mcp") + await hooks[0](request) + assert request.headers.get("X-Custom") == "test-value" + finally: + _mcp_call_headers.reset(token) + finally: + # Ensure any created httpx client is properly closed + if getattr(tool, "_httpx_client", None) is not None: + await tool._httpx_client.aclose() + + +async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client(): + """Test that header_provider works when the user provides their own httpx client.""" + import httpx + + from agent_framework._mcp import _mcp_call_headers + + user_client = httpx.AsyncClient(headers={"X-Base": "static"}) + + tool = MCPStreamableHTTPTool( + name="test", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda kw: {"X-Dynamic": kw.get("dynamic", "")}, + ) + + with patch("agent_framework._mcp.streamable_http_client"): + tool.get_mcp_client() + + # The user's client should still be used + assert tool._httpx_client is user_client + hooks = user_client.event_hooks.get("request", []) + assert len(hooks) == 1 + + # Verify the hook injects headers + token = _mcp_call_headers.set({"X-Dynamic": "per-request"}) + try: + request = httpx.Request("POST", "http://example.com/mcp") + await hooks[0](request) + assert request.headers.get("X-Dynamic") == "per-request" + finally: + _mcp_call_headers.reset(token) + + await user_client.aclose() + + +async def test_mcp_streamable_http_tool_header_provider_via_invoke_with_context(): + """Test that header_provider receives kwargs via FunctionTool.invoke with FunctionInvocationContext. + + This exercises the full pipeline: FunctionInvocationContext.kwargs -> FunctionTool.invoke + -> MCPStreamableHTTPTool.call_tool -> header_provider. + """ + from agent_framework._mcp import _mcp_call_headers + + observed_headers: list[dict[str, str]] = [] + original_call_tool = MCPStreamableHTTPTool.call_tool + + async def spy_call_tool(self, tool_name, **kwargs): + # Capture the contextvar value set by call_tool before delegating + result = await original_call_tool(self, tool_name, **kwargs) + try: + observed_headers.append(_mcp_call_headers.get()) + except LookupError: + observed_headers.append({}) + return result + + class _TestServer(MCPStreamableHTTPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Says hello", + inputSchema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ] + ) + ) + self.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) + ) + self.session.send_ping = AsyncMock() + self.is_connected = True + + def get_mcp_client(self): + return None + + provider_received: list[dict] = [] + + def provider(kwargs): + provider_received.append(dict(kwargs)) + return {"X-Some-Token": kwargs.get("some_token", "")} + + server = _TestServer( + name="test", + url="http://example.com/mcp", + header_provider=provider, + ) + async with server: + await server.load_tools() + func = server.functions[0] + + # Build a FunctionInvocationContext with runtime kwargs, as the agent framework would + context = FunctionInvocationContext( + function=func, + arguments={"name": "Alice"}, + kwargs={"some_token": "my-secret"}, + ) + + with patch.object(MCPStreamableHTTPTool, "call_tool", spy_call_tool): + result = await func.invoke(arguments={"name": "Alice"}, context=context) + + # Verify the invoke produced a result + assert isinstance(result, list) + assert result[0].text == "Hello!" + + # Verify header_provider was called with the runtime kwargs + assert len(provider_received) == 1 + assert provider_received[0]["some_token"] == "my-secret" + + # Verify session.call_tool was called with the tool arguments (not the runtime kwargs) + server.session.call_tool.assert_called_once() + call_args = server.session.call_tool.call_args + assert call_args.kwargs.get("arguments", {}).get("name") == "Alice" + + # endregion diff --git a/python/packages/core/tests/core/test_middleware.py b/python/packages/core/tests/core/test_middleware.py index 0026cbf98f..ac08199b4a 100644 --- a/python/packages/core/tests/core/test_middleware.py +++ b/python/packages/core/tests/core/test_middleware.py @@ -38,7 +38,7 @@ class TestAgentContext: def test_init_with_defaults(self, mock_agent: SupportsAgentRun) -> None: """Test AgentContext initialization with default values.""" - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) assert context.agent is mock_agent @@ -48,7 +48,7 @@ class TestAgentContext: def test_init_with_custom_values(self, mock_agent: SupportsAgentRun) -> None: """Test AgentContext initialization with custom values.""" - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] metadata = {"key": "value"} context = AgentContext(agent=mock_agent, messages=messages, stream=True, metadata=metadata) @@ -61,7 +61,7 @@ class TestAgentContext: """Test AgentContext initialization with session parameter.""" from agent_framework import AgentSession - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] session = AgentSession() context = AgentContext(agent=mock_agent, messages=messages, session=session) @@ -100,7 +100,7 @@ class TestChatContext: def test_init_with_defaults(self, mock_chat_client: Any) -> None: """Test ChatContext initialization with default values.""" - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) @@ -113,7 +113,7 @@ class TestChatContext: def test_init_with_custom_values(self, mock_chat_client: Any) -> None: """Test ChatContext initialization with custom values.""" - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {"temperature": 0.5} metadata = {"key": "value"} @@ -167,10 +167,10 @@ class TestAgentMiddlewarePipeline: async def test_execute_no_middleware(self, mock_agent: SupportsAgentRun) -> None: """Test pipeline execution with no middleware.""" pipeline = AgentMiddlewarePipeline() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) - expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", contents=["response"])]) async def final_handler(ctx: AgentContext) -> AgentResponse: return expected_response @@ -193,10 +193,10 @@ class TestAgentMiddlewarePipeline: middleware = OrderTrackingMiddleware("test") pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) - expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", contents=["response"])]) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") @@ -209,7 +209,7 @@ class TestAgentMiddlewarePipeline: async def test_execute_stream_no_middleware(self, mock_agent: SupportsAgentRun) -> None: """Test pipeline streaming execution with no middleware.""" pipeline = AgentMiddlewarePipeline() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -244,7 +244,7 @@ class TestAgentMiddlewarePipeline: middleware = StreamOrderTrackingMiddleware("test") pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -270,14 +270,14 @@ class TestAgentMiddlewarePipeline: """Test pipeline execution with termination before next().""" middleware = self.PreNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) execution_order: list[str] = [] async def final_handler(ctx: AgentContext) -> AgentResponse: # Handler should not be executed when terminated before next() execution_order.append("handler") - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) response = await pipeline.execute(context, final_handler) assert response is None @@ -288,13 +288,13 @@ class TestAgentMiddlewarePipeline: """Test pipeline execution with termination after next().""" middleware = self.PostNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) execution_order: list[str] = [] async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) response = await pipeline.execute(context, final_handler) assert response is not None @@ -306,7 +306,7 @@ class TestAgentMiddlewarePipeline: """Test pipeline streaming execution with termination before next().""" middleware = self.PreNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=True) execution_order: list[str] = [] @@ -334,7 +334,7 @@ class TestAgentMiddlewarePipeline: """Test pipeline streaming execution with termination after next().""" middleware = self.PostNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=True) execution_order: list[str] = [] @@ -371,11 +371,11 @@ class TestAgentMiddlewarePipeline: middleware = SessionCapturingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] session = AgentSession() context = AgentContext(agent=mock_agent, messages=messages, session=session) - expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", contents=["response"])]) async def final_handler(ctx: AgentContext) -> AgentResponse: return expected_response @@ -396,10 +396,10 @@ class TestAgentMiddlewarePipeline: middleware = SessionCapturingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, session=None) - expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", contents=["response"])]) async def final_handler(ctx: AgentContext) -> AgentResponse: return expected_response @@ -563,11 +563,11 @@ class TestChatMiddlewarePipeline: async def test_execute_no_middleware(self, mock_chat_client: Any) -> None: """Test pipeline execution with no middleware.""" pipeline = ChatMiddlewarePipeline() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) - expected_response = ChatResponse(messages=[Message(role="assistant", text="response")]) + expected_response = ChatResponse(messages=[Message(role="assistant", contents=["response"])]) async def final_handler(ctx: ChatContext) -> ChatResponse: return expected_response @@ -590,11 +590,11 @@ class TestChatMiddlewarePipeline: middleware = OrderTrackingChatMiddleware("test") pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) - expected_response = ChatResponse(messages=[Message(role="assistant", text="response")]) + expected_response = ChatResponse(messages=[Message(role="assistant", contents=["response"])]) async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") @@ -607,7 +607,7 @@ class TestChatMiddlewarePipeline: async def test_execute_stream_no_middleware(self, mock_chat_client: Any) -> None: """Test pipeline streaming execution with no middleware.""" pipeline = ChatMiddlewarePipeline() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) @@ -642,7 +642,7 @@ class TestChatMiddlewarePipeline: middleware = StreamOrderTrackingChatMiddleware("test") pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) @@ -669,7 +669,7 @@ class TestChatMiddlewarePipeline: """Test pipeline execution with termination before next().""" middleware = self.PreNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) execution_order: list[str] = [] @@ -677,7 +677,7 @@ class TestChatMiddlewarePipeline: async def final_handler(ctx: ChatContext) -> ChatResponse: # Handler should not be executed when terminated before next() execution_order.append("handler") - return ChatResponse(messages=[Message(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", contents=["response"])]) response = await pipeline.execute(context, final_handler) assert response is None @@ -688,14 +688,14 @@ class TestChatMiddlewarePipeline: """Test pipeline execution with termination after next().""" middleware = self.PostNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) execution_order: list[str] = [] async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") - return ChatResponse(messages=[Message(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", contents=["response"])]) response = await pipeline.execute(context, final_handler) assert response is not None @@ -707,7 +707,7 @@ class TestChatMiddlewarePipeline: """Test pipeline streaming execution with termination before next().""" middleware = self.PreNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) execution_order: list[str] = [] @@ -732,7 +732,7 @@ class TestChatMiddlewarePipeline: """Test pipeline streaming execution with termination after next().""" middleware = self.PostNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) execution_order: list[str] = [] @@ -774,12 +774,12 @@ class TestClassBasedMiddleware: middleware = MetadataAgentMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: metadata_updates.append("handler") - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) @@ -835,12 +835,12 @@ class TestFunctionBasedMiddleware: execution_order.append("function_after") pipeline = AgentMiddlewarePipeline(test_agent_middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) @@ -894,12 +894,12 @@ class TestMixedMiddleware: execution_order.append("function_after") pipeline = AgentMiddlewarePipeline(ClassMiddleware(), function_middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) @@ -956,13 +956,13 @@ class TestMixedMiddleware: execution_order.append("function_after") pipeline = ChatMiddlewarePipeline(ClassChatMiddleware(), function_chat_middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") - return ChatResponse(messages=[Message(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) @@ -997,12 +997,12 @@ class TestMultipleMiddlewareOrdering: middleware = [FirstMiddleware(), SecondMiddleware(), ThirdMiddleware()] pipeline = AgentMiddlewarePipeline(*middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) @@ -1081,13 +1081,13 @@ class TestMultipleMiddlewareOrdering: middleware = [FirstChatMiddleware(), SecondChatMiddleware(), ThirdChatMiddleware()] pipeline = ChatMiddlewarePipeline(*middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") - return ChatResponse(messages=[Message(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) @@ -1133,13 +1133,13 @@ class TestContextContentValidation: middleware = ContextValidationMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: # Verify metadata was set by middleware assert ctx.metadata.get("validated") is True - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) assert result is not None @@ -1212,14 +1212,14 @@ class TestContextContentValidation: middleware = ChatContextValidationMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {"temperature": 0.5} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) async def final_handler(ctx: ChatContext) -> ChatResponse: # Verify metadata was set by middleware assert ctx.metadata.get("validated") is True - return ChatResponse(messages=[Message(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", contents=["response"])]) result = await pipeline.execute(context, final_handler) assert result is not None @@ -1239,14 +1239,14 @@ class TestStreamingScenarios: middleware = StreamingFlagMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] # Test non-streaming context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: streaming_flags.append(ctx.stream) - return AgentResponse(messages=[Message(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) await pipeline.execute(context, final_handler) @@ -1280,7 +1280,7 @@ class TestStreamingScenarios: middleware = StreamProcessingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_stream_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -1320,7 +1320,7 @@ class TestStreamingScenarios: middleware = ChatStreamingFlagMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} # Test non-streaming @@ -1328,7 +1328,7 @@ class TestStreamingScenarios: async def final_handler(ctx: ChatContext) -> ChatResponse: streaming_flags.append(ctx.stream) - return ChatResponse(messages=[Message(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", contents=["response"])]) await pipeline.execute(context, final_handler) @@ -1362,7 +1362,7 @@ class TestStreamingScenarios: middleware = ChatStreamProcessingMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) @@ -1442,7 +1442,7 @@ class TestMiddlewareExecutionControl: middleware = NoNextMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) handler_called = False @@ -1450,7 +1450,7 @@ class TestMiddlewareExecutionControl: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[Message(role="assistant", text="should not execute")]) + return AgentResponse(messages=[Message(role="assistant", contents=["should not execute"])]) result = await pipeline.execute(context, final_handler) @@ -1469,7 +1469,7 @@ class TestMiddlewareExecutionControl: middleware = NoNextStreamingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=True) handler_called = False @@ -1539,7 +1539,7 @@ class TestMiddlewareExecutionControl: await call_next() pipeline = AgentMiddlewarePipeline(FirstMiddleware(), SecondMiddleware()) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) handler_called = False @@ -1547,7 +1547,7 @@ class TestMiddlewareExecutionControl: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[Message(role="assistant", text="should not execute")]) + return AgentResponse(messages=[Message(role="assistant", contents=["should not execute"])]) result = await pipeline.execute(context, final_handler) @@ -1566,7 +1566,7 @@ class TestMiddlewareExecutionControl: middleware = NoNextChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) @@ -1575,7 +1575,7 @@ class TestMiddlewareExecutionControl: async def final_handler(ctx: ChatContext) -> ChatResponse: nonlocal handler_called handler_called = True - return ChatResponse(messages=[Message(role="assistant", text="should not execute")]) + return ChatResponse(messages=[Message(role="assistant", contents=["should not execute"])]) result = await pipeline.execute(context, final_handler) @@ -1594,7 +1594,7 @@ class TestMiddlewareExecutionControl: middleware = NoNextStreamingChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) @@ -1639,7 +1639,7 @@ class TestMiddlewareExecutionControl: await call_next() pipeline = ChatMiddlewarePipeline(FirstChatMiddleware(), SecondChatMiddleware()) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] chat_options: dict[str, Any] = {} context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) @@ -1648,7 +1648,7 @@ class TestMiddlewareExecutionControl: async def final_handler(ctx: ChatContext) -> ChatResponse: nonlocal handler_called handler_called = True - return ChatResponse(messages=[Message(role="assistant", text="should not execute")]) + return ChatResponse(messages=[Message(role="assistant", contents=["should not execute"])]) result = await pipeline.execute(context, final_handler) diff --git a/python/packages/core/tests/core/test_middleware_context_result.py b/python/packages/core/tests/core/test_middleware_context_result.py index 6d9eec351b..303feb0344 100644 --- a/python/packages/core/tests/core/test_middleware_context_result.py +++ b/python/packages/core/tests/core/test_middleware_context_result.py @@ -39,7 +39,7 @@ class TestResultOverrideMiddleware: async def test_agent_middleware_response_override_non_streaming(self, mock_agent: SupportsAgentRun) -> None: """Test that agent middleware can override response for non-streaming execution.""" - override_response = AgentResponse(messages=[Message(role="assistant", text="overridden response")]) + override_response = AgentResponse(messages=[Message(role="assistant", contents=["overridden response"])]) class ResponseOverrideMiddleware(AgentMiddleware): async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: @@ -49,7 +49,7 @@ class TestResultOverrideMiddleware: middleware = ResponseOverrideMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages) handler_called = False @@ -57,7 +57,7 @@ class TestResultOverrideMiddleware: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[Message(role="assistant", text="original response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["original response"])]) result = await pipeline.execute(context, final_handler) @@ -83,7 +83,7 @@ class TestResultOverrideMiddleware: middleware = StreamResponseOverrideMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -146,7 +146,7 @@ class TestResultOverrideMiddleware: # Then conditionally override based on content if any("special" in msg.text for msg in context.messages if msg.text): context.result = AgentResponse( - messages=[Message(role="assistant", text="Special response from middleware!")] + messages=[Message(role="assistant", contents=["Special response from middleware!"])] ) # Create Agent with override middleware @@ -154,14 +154,14 @@ class TestResultOverrideMiddleware: agent = Agent(client=mock_chat_client, middleware=[middleware]) # Test override case - override_messages = [Message(role="user", text="Give me a special response")] + override_messages = [Message(role="user", contents=["Give me a special response"])] override_response = await agent.run(override_messages) assert override_response.messages[0].text == "Special response from middleware!" # Verify chat client was called since middleware called next() assert mock_chat_client.call_count == 1 # Test normal case - normal_messages = [Message(role="user", text="Normal request")] + normal_messages = [Message(role="user", contents=["Normal request"])] normal_response = await agent.run(normal_messages) assert normal_response.messages[0].text == "test response" # Verify chat client was called for normal case @@ -190,7 +190,7 @@ class TestResultOverrideMiddleware: agent = Agent(client=mock_chat_client, middleware=[middleware]) # Test streaming override case - override_messages = [Message(role="user", text="Give me a custom stream")] + override_messages = [Message(role="user", contents=["Give me a custom stream"])] override_updates: list[AgentResponseUpdate] = [] async for update in agent.run(override_messages, stream=True): override_updates.append(update) @@ -201,7 +201,7 @@ class TestResultOverrideMiddleware: assert override_updates[2].text == " response!" # Test normal streaming case - normal_messages = [Message(role="user", text="Normal streaming request")] + normal_messages = [Message(role="user", contents=["Normal streaming request"])] normal_updates: list[AgentResponseUpdate] = [] async for update in agent.run(normal_messages, stream=True): normal_updates.append(update) @@ -228,10 +228,10 @@ class TestResultOverrideMiddleware: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[Message(role="assistant", text="executed response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["executed response"])]) # Test case where next() is NOT called - no_execute_messages = [Message(role="user", text="Don't run this")] + no_execute_messages = [Message(role="user", contents=["Don't run this"])] no_execute_context = AgentContext(agent=mock_agent, messages=no_execute_messages, stream=False) no_execute_result = await pipeline.execute(no_execute_context, final_handler) @@ -243,7 +243,7 @@ class TestResultOverrideMiddleware: handler_called = False # Test case where next() IS called - execute_messages = [Message(role="user", text="Please execute this")] + execute_messages = [Message(role="user", contents=["Please execute this"])] execute_context = AgentContext(agent=mock_agent, messages=execute_messages, stream=False) execute_result = await pipeline.execute(execute_context, final_handler) @@ -321,11 +321,11 @@ class TestResultObservability: middleware = ObservabilityMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=False) async def final_handler(ctx: AgentContext) -> AgentResponse: - return AgentResponse(messages=[Message(role="assistant", text="executed response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["executed response"])]) result = await pipeline.execute(context, final_handler) @@ -384,16 +384,16 @@ class TestResultObservability: if "modify" in context.result.messages[0].text: # Override after observing context.result = AgentResponse( - messages=[Message(role="assistant", text="modified after execution")] + messages=[Message(role="assistant", contents=["modified after execution"])] ) middleware = PostExecutionOverrideMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] context = AgentContext(agent=mock_agent, messages=messages, stream=False) async def final_handler(ctx: AgentContext) -> AgentResponse: - return AgentResponse(messages=[Message(role="assistant", text="response to modify")]) + return AgentResponse(messages=[Message(role="assistant", contents=["response to modify"])]) result = await pipeline.execute(context, final_handler) diff --git a/python/packages/core/tests/core/test_middleware_with_agent.py b/python/packages/core/tests/core/test_middleware_with_agent.py index 69d08482d3..9f56768770 100644 --- a/python/packages/core/tests/core/test_middleware_with_agent.py +++ b/python/packages/core/tests/core/test_middleware_with_agent.py @@ -15,6 +15,7 @@ from agent_framework import ( ChatResponse, ChatResponseUpdate, Content, + ContextProvider, FunctionInvocationContext, FunctionMiddleware, FunctionTool, @@ -55,7 +56,7 @@ class TestChatAgentClassBasedMiddleware: agent = Agent(client=client, middleware=[middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -104,7 +105,7 @@ class TestChatAgentClassBasedMiddleware: middleware = TrackingFunctionMiddleware("function_middleware") agent = Agent(client=chat_client_base, middleware=[middleware]) - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) assert response is not None @@ -134,8 +135,8 @@ class TestChatAgentFunctionBasedMiddleware: # Execute the agent with multiple messages messages = [ - Message(role="user", text="message1"), - Message(role="user", text="message2"), # This should not be processed due to termination + Message(role="user", contents=["message1"]), + Message(role="user", contents=["message2"]), # This should not be processed due to termination ] response = await agent.run(messages) @@ -162,8 +163,8 @@ class TestChatAgentFunctionBasedMiddleware: # Execute the agent with multiple messages messages = [ - Message(role="user", text="message1"), - Message(role="user", text="message2"), + Message(role="user", contents=["message1"]), + Message(role="user", contents=["message2"]), ] response = await agent.run(messages) @@ -228,7 +229,7 @@ class TestChatAgentFunctionBasedMiddleware: agent = Agent(client=client, middleware=[tracking_agent_middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -265,7 +266,7 @@ class TestChatAgentFunctionBasedMiddleware: execution_order.append("function_function_after") agent = Agent(client=chat_client_base, middleware=[tracking_function_middleware]) - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) assert response is not None @@ -302,7 +303,7 @@ class TestChatAgentStreamingMiddleware: ] # Execute streaming - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] updates: list[AgentResponseUpdate] = [] async for update in agent.run(messages, stream=True): updates.append(update) @@ -332,7 +333,7 @@ class TestChatAgentStreamingMiddleware: # Create Agent with middleware middleware = FlagTrackingMiddleware() agent = Agent(client=client, middleware=[middleware]) - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] # Test non-streaming execution response = await agent.run(messages) @@ -371,7 +372,7 @@ class TestChatAgentMultipleMiddlewareOrdering: agent = Agent(client=client, middleware=[middleware1, middleware2, middleware3]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -423,7 +424,7 @@ class TestChatAgentMultipleMiddlewareOrdering: function_function_middleware, ], ) - await agent.run([Message(role="user", text="test")]) + await agent.run([Message(role="user", contents=["test"])]) async def test_mixed_middleware_types_with_supported_client(self, chat_client_base: "MockBaseChatClient") -> None: """Test mixed class and function-based middleware with a full chat client.""" @@ -456,7 +457,7 @@ class TestChatAgentMultipleMiddlewareOrdering: ], ) - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) assert response is not None @@ -464,6 +465,31 @@ class TestChatAgentMultipleMiddlewareOrdering: expected_order = ["class_agent_before", "function_agent_before", "function_agent_after", "class_agent_after"] assert execution_order == expected_order + async def test_provider_added_agent_middleware_is_rejected(self, chat_client_base: "MockBaseChatClient") -> None: + """Test provider-added agent middleware is rejected explicitly.""" + + @agent_middleware + async def provider_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + class ProviderMiddlewareContextProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="provider-middleware") + + async def before_run(self, *, agent, session, context, state) -> None: + context.extend_middleware(self.source_id, provider_middleware) + + agent = Agent( + client=chat_client_base, + context_providers=[ProviderMiddlewareContextProvider()], + ) + + with pytest.raises( + MiddlewareException, + match="Context providers may only add chat or function middleware", + ): + await agent.run([Message(role="user", contents=["test message"])]) + # region Tool Functions for Testing @@ -521,7 +547,7 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ) - final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]) chat_client_base.run_responses = [function_call_response, final_response] @@ -534,7 +560,7 @@ class TestChatAgentFunctionMiddlewareWithTools: ) # Execute the agent - messages = [Message(role="user", text="Get weather for Seattle")] + messages = [Message(role="user", contents=["Get weather for Seattle"])] response = await agent.run(messages) # Verify response @@ -583,7 +609,7 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ) - final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]) chat_client_base.run_responses = [function_call_response, final_response] @@ -595,7 +621,7 @@ class TestChatAgentFunctionMiddlewareWithTools: ) # Execute the agent - messages = [Message(role="user", text="Get weather for San Francisco")] + messages = [Message(role="user", contents=["Get weather for San Francisco"])] response = await agent.run(messages) # Verify response @@ -657,7 +683,7 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ) - final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]) chat_client_base.run_responses = [function_call_response, final_response] @@ -669,7 +695,7 @@ class TestChatAgentFunctionMiddlewareWithTools: ) # Execute the agent - messages = [Message(role="user", text="Get weather for New York")] + messages = [Message(role="user", contents=["Get weather for New York"])] response = await agent.run(messages) # Verify response @@ -769,7 +795,7 @@ class TestChatAgentFunctionMiddlewareWithTools: agent = Agent(client=chat_client_base, middleware=[kwargs_middleware], tools=[sample_tool_function]) # Execute the agent with custom parameters passed as kwargs - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages, options={"additional_function_arguments": {"custom_param": "test_value"}}) # Verify response @@ -815,14 +841,14 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ), - ChatResponse(messages=[Message(role="assistant", text="Done!")]), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), ] agent = Agent(client=chat_client_base, middleware=[capture_middleware], tools=[sample_tool_function]) session_metadata = {"tenant": "acme-corp", "region": "us-west"} await agent.run( - [Message(role="user", text="Get weather")], + [Message(role="user", contents=["Get weather"])], function_invocation_kwargs={ "user_id": "user-456", "session_metadata": session_metadata, @@ -859,13 +885,13 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ), - ChatResponse(messages=[Message(role="assistant", text="Done!")]), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), ] agent = Agent(client=chat_client_base, middleware=[capture_middleware], tools=[sample_tool_function]) await agent.run( - [Message(role="user", text="Get weather")], + [Message(role="user", contents=["Get weather"])], function_invocation_kwargs={ "user_id": "from-kwargs", "tenant_id": "from-kwargs", @@ -914,13 +940,13 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ), - ChatResponse(messages=[Message(role="assistant", text="Done!")]), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), ] agent = Agent(client=chat_client_base, middleware=[capture_middleware], tools=[sample_tool_function]) await agent.run( - [Message(role="user", text="Get weather for both cities")], + [Message(role="user", contents=["Get weather for both cities"])], function_invocation_kwargs={ "user_id": "user-456", "request_id": "req-001", @@ -958,12 +984,12 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ), - ChatResponse(messages=[Message(role="assistant", text="Done!")]), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), ] agent = Agent(client=chat_client_base, middleware=[capture_middleware], tools=[sample_tool_function]) - await agent.run([Message(role="user", text="Get weather")]) + await agent.run([Message(role="user", contents=["Get weather"])]) # No runtime kwargs should be present assert "user_id" not in captured_kwargs @@ -1329,7 +1355,7 @@ class TestRunLevelMiddleware: ) ] ) - final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]) chat_client_base.run_responses = [function_call_response, final_response] # Create agent with agent-level middleware @@ -1420,7 +1446,7 @@ class TestMiddlewareDecoratorLogic: ) ] ) - final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]) chat_client_base.responses = [function_call_response, final_response] # Should work without errors @@ -1430,7 +1456,7 @@ class TestMiddlewareDecoratorLogic: tools=[custom_tool_wrapped], ) - response = await agent.run([Message(role="user", text="test")]) + response = await agent.run([Message(role="user", contents=["test"])]) assert response is not None assert "decorator_type_match_agent" in execution_order @@ -1451,7 +1477,7 @@ class TestMiddlewareDecoratorLogic: await call_next() agent = Agent(client=client, middleware=[mismatched_middleware]) - await agent.run([Message(role="user", text="test")]) + await agent.run([Message(role="user", contents=["test"])]) async def test_only_decorator_specified(self, chat_client_base: "MockBaseChatClient") -> None: """Only decorator specified - rely on decorator.""" @@ -1491,7 +1517,7 @@ class TestMiddlewareDecoratorLogic: ) ] ) - final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]) chat_client_base.responses = [function_call_response, final_response] # Should work - relies on decorator @@ -1501,7 +1527,7 @@ class TestMiddlewareDecoratorLogic: tools=[custom_tool_wrapped], ) - response = await agent.run([Message(role="user", text="test")]) + response = await agent.run([Message(role="user", contents=["test"])]) assert response is not None assert "decorator_only_agent" in execution_order @@ -1547,7 +1573,7 @@ class TestMiddlewareDecoratorLogic: ) ] ) - final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]) chat_client_base.responses = [function_call_response, final_response] # Should work - relies on type annotations @@ -1555,7 +1581,7 @@ class TestMiddlewareDecoratorLogic: client=chat_client_base, middleware=[type_only_agent, type_only_function], tools=[custom_tool_wrapped] ) - response = await agent.run([Message(role="user", text="test")]) + response = await agent.run([Message(role="user", contents=["test"])]) assert response is not None assert "type_only_agent" in execution_order @@ -1570,7 +1596,7 @@ class TestMiddlewareDecoratorLogic: # Should raise MiddlewareException with pytest.raises(MiddlewareException, match="Cannot determine middleware type"): agent = Agent(client=client, middleware=[no_info_middleware]) - await agent.run([Message(role="user", text="test")]) + await agent.run([Message(role="user", contents=["test"])]) async def test_insufficient_parameters_error(self, client: Any) -> None: """Test that middleware with insufficient parameters raises an error.""" @@ -1584,7 +1610,7 @@ class TestMiddlewareDecoratorLogic: pass agent = Agent(client=client, middleware=[insufficient_params_middleware]) - await agent.run([Message(role="user", text="test")]) + await agent.run([Message(role="user", contents=["test"])]) async def test_decorator_markers_preserved(self) -> None: """Test that decorator markers are properly set on functions.""" @@ -1656,7 +1682,7 @@ class TestChatAgentSessionBehavior: session = agent.create_session() # First run - first_messages = [Message(role="user", text="first message")] + first_messages = [Message(role="user", contents=["first message"])] first_response = await agent.run(first_messages, session=session) # Verify first response @@ -1664,7 +1690,7 @@ class TestChatAgentSessionBehavior: assert len(first_response.messages) > 0 # Second run - use the same thread - second_messages = [Message(role="user", text="second message")] + second_messages = [Message(role="user", contents=["second message"])] second_response = await agent.run(second_messages, session=session) # Verify second response @@ -1736,7 +1762,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -1763,7 +1789,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[tracking_chat_middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -1787,7 +1813,7 @@ class TestChatAgentChatMiddleware: if msg.role == "system": continue original_text = msg.text or "" - context.messages[idx] = Message(role=msg.role, text=f"MODIFIED: {original_text}") + context.messages[idx] = Message(role=msg.role, contents=[f"MODIFIED: {original_text}"]) break await call_next() @@ -1796,7 +1822,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[message_modifier_middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify that the message was modified (MockBaseChatClient echoes back the input) @@ -1810,7 +1836,7 @@ class TestChatAgentChatMiddleware: async def response_override_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Override the response without calling next() context.result = ChatResponse( - messages=[Message(role="assistant", text="MiddlewareTypes overridden response")], + messages=[Message(role="assistant", contents=["MiddlewareTypes overridden response"])], response_id="middleware-response-123", ) context.terminate = True @@ -1820,7 +1846,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[response_override_middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify that the response was overridden @@ -1850,7 +1876,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[first_middleware, second_middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -1888,7 +1914,7 @@ class TestChatAgentChatMiddleware: ] # Execute streaming - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] updates: list[AgentResponseUpdate] = [] async for update in agent.run(messages, stream=True): updates.append(update) @@ -1911,7 +1937,9 @@ class TestChatAgentChatMiddleware: async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("middleware_before") # Set a custom response since we're terminating - context.result = ChatResponse(messages=[Message(role="assistant", text="Terminated by middleware")]) + context.result = ChatResponse( + messages=[Message(role="assistant", contents=["Terminated by middleware"])] + ) raise MiddlewareTermination # We call next() but since terminate=True, execution should stop await call_next() @@ -1922,7 +1950,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[PreTerminationChatMiddleware()]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response was from middleware @@ -1947,7 +1975,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[PostTerminationChatMiddleware()]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response is from actual execution @@ -1986,7 +2014,7 @@ class TestChatAgentChatMiddleware: middleware=[chat_middleware, function_middleware, agent_middleware], tools=[sample_tool_function], ) - await agent.run([Message(role="user", text="test")]) + await agent.run([Message(role="user", contents=["test"])]) assert execution_order == [ "agent_middleware_before", @@ -2015,7 +2043,7 @@ class TestChatAgentChatMiddleware: ) ] ), - ChatResponse(messages=[Message(role="assistant", text="Final response")]), + ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]), ] async def tracking_agent_middleware( @@ -2050,7 +2078,7 @@ class TestChatAgentChatMiddleware: tools=[sample_tool_function], ) - response = await agent.run([Message(role="user", text="test")]) + response = await agent.run([Message(role="user", contents=["test"])]) assert response is not None assert client.call_count == 2 @@ -2066,6 +2094,121 @@ class TestChatAgentChatMiddleware: "agent_middleware_after", ] + async def test_provider_added_chat_and_function_middleware_are_forwarded( + self, chat_client_base: "MockBaseChatClient" + ) -> None: + """Test provider-added chat and function middleware forwarding and ordering.""" + execution_order: list[str] = [] + + @chat_middleware + async def constructor_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + execution_order.append("constructor_chat_before") + await call_next() + execution_order.append("constructor_chat_after") + + @chat_middleware + async def provider_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + execution_order.append("provider_chat_before") + await call_next() + execution_order.append("provider_chat_after") + + @chat_middleware + async def run_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + execution_order.append("run_chat_before") + await call_next() + execution_order.append("run_chat_after") + + @function_middleware + async def constructor_function_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + execution_order.append("constructor_function_before") + await call_next() + execution_order.append("constructor_function_after") + + @function_middleware + async def provider_function_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + execution_order.append("provider_function_before") + await call_next() + execution_order.append("provider_function_after") + + @function_middleware + async def run_function_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + execution_order.append("run_function_before") + await call_next() + execution_order.append("run_function_after") + + class ProviderMiddlewareContextProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="provider-middleware") + + async def before_run(self, *, agent, session, context, state) -> None: + context.extend_middleware( + self.source_id, + [ + provider_chat_middleware, + provider_function_middleware, + ], + ) + + chat_client_base.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_provider", + name="sample_tool_function", + arguments='{"location": "Seattle"}', + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", contents=["Final response"])]), + ] + + agent = Agent( + client=chat_client_base, + middleware=[constructor_chat_middleware, constructor_function_middleware], + context_providers=[ProviderMiddlewareContextProvider()], + tools=[sample_tool_function], + ) + + response = await agent.run( + [Message(role="user", contents=["Get weather for Seattle"])], + middleware=[run_chat_middleware, run_function_middleware], + ) + + assert response is not None + assert chat_client_base.call_count == 2 + assert response.messages[-1].text == "Final response" + assert execution_order == [ + "constructor_chat_before", + "run_chat_before", + "provider_chat_before", + "provider_chat_after", + "run_chat_after", + "constructor_chat_after", + "constructor_function_before", + "run_function_before", + "provider_function_before", + "provider_function_after", + "run_function_after", + "constructor_function_after", + "constructor_chat_before", + "run_chat_before", + "provider_chat_before", + "provider_chat_after", + "run_chat_after", + "constructor_chat_after", + ] + async def test_agent_middleware_can_access_and_override_options(self) -> None: """Test that agent middleware can access and override runtime options.""" captured_options: dict[str, Any] = {} @@ -2089,7 +2232,7 @@ class TestChatAgentChatMiddleware: agent = Agent(client=client, middleware=[kwargs_middleware]) # Execute the agent with runtime options - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run( messages, options={"temperature": 0.7, "max_tokens": 100, "custom_param": "test_value"}, @@ -2147,7 +2290,7 @@ class TestChatAgentChatMiddleware: # yield AgentResponseUpdate() # return _stream() -# return AgentResponse(messages=[Message(role="assistant", text="response")]) +# return AgentResponse(messages=[Message(role="assistant", contents=["response"])]) # def get_new_thread(self, **kwargs): # return None diff --git a/python/packages/core/tests/core/test_middleware_with_chat.py b/python/packages/core/tests/core/test_middleware_with_chat.py index b3393c2248..14243c6578 100644 --- a/python/packages/core/tests/core/test_middleware_with_chat.py +++ b/python/packages/core/tests/core/test_middleware_with_chat.py @@ -43,7 +43,7 @@ class TestChatMiddleware: chat_client_base.chat_middleware = [LoggingChatMiddleware()] # Execute chat client directly - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await chat_client_base.get_response(messages) # Verify response @@ -68,7 +68,7 @@ class TestChatMiddleware: chat_client_base.chat_middleware = [logging_chat_middleware] # Execute chat client directly - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await chat_client_base.get_response(messages) # Verify response @@ -87,14 +87,14 @@ class TestChatMiddleware: # Modify the first message by adding a prefix if context.messages and len(context.messages) > 0: original_text = context.messages[0].text or "" - context.messages[0] = Message(role=context.messages[0].role, text=f"MODIFIED: {original_text}") + context.messages[0] = Message(role=context.messages[0].role, contents=[f"MODIFIED: {original_text}"]) await call_next() # Add middleware to chat client chat_client_base.chat_middleware = [message_modifier_middleware] # Execute chat client - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await chat_client_base.get_response(messages) # Verify that the message was modified (MockChatClient echoes back the input) @@ -110,7 +110,7 @@ class TestChatMiddleware: async def response_override_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Override the response without calling next() context.result = ChatResponse( - messages=[Message(role="assistant", text="MiddlewareTypes overridden response")], + messages=[Message(role="assistant", contents=["MiddlewareTypes overridden response"])], response_id="middleware-response-123", ) context.terminate = True @@ -119,7 +119,7 @@ class TestChatMiddleware: chat_client_base.chat_middleware = [response_override_middleware] # Execute chat client - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await chat_client_base.get_response(messages) # Verify that the response was overridden @@ -148,7 +148,7 @@ class TestChatMiddleware: chat_client_base.chat_middleware = [first_middleware, second_middleware] # Execute chat client - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await chat_client_base.get_response(messages) # Verify response @@ -179,7 +179,7 @@ class TestChatMiddleware: agent = Agent(client=client, middleware=[agent_level_chat_middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -213,7 +213,7 @@ class TestChatMiddleware: agent = Agent(client=chat_client_base, middleware=[first_middleware, second_middleware]) # Execute the agent - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await agent.run(messages) # Verify response @@ -252,7 +252,7 @@ class TestChatMiddleware: chat_client_base.chat_middleware = [streaming_middleware] # Execute streaming response - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] updates: list[object] = [] async for update in chat_client_base.get_response(messages, stream=True): updates.append(update) @@ -274,7 +274,7 @@ class TestChatMiddleware: await call_next() # First call with run-level middleware - messages = [Message(role="user", text="first message")] + messages = [Message(role="user", contents=["first message"])] response1 = await chat_client_base.get_response( messages, client_kwargs={"middleware": [counting_middleware]}, @@ -283,13 +283,13 @@ class TestChatMiddleware: assert execution_count["count"] == 1 # Second call WITHOUT run-level middleware - should not execute the middleware - messages = [Message(role="user", text="second message")] + messages = [Message(role="user", contents=["second message"])] response2 = await chat_client_base.get_response(messages) assert response2 is not None assert execution_count["count"] == 1 # Should still be 1, not 2 # Third call with run-level middleware again - should execute - messages = [Message(role="user", text="third message")] + messages = [Message(role="user", contents=["third message"])] response3 = await chat_client_base.get_response( messages, client_kwargs={"middleware": [counting_middleware]}, @@ -310,7 +310,7 @@ class TestChatMiddleware: async def fake_inner_get_response(**kwargs: Any) -> ChatResponse: assert "middleware" not in kwargs - return ChatResponse(messages=[Message(role="assistant", text="ok")]) + return ChatResponse(messages=[Message(role="assistant", contents=["ok"])]) with patch.object( chat_client_base, @@ -318,7 +318,7 @@ class TestChatMiddleware: side_effect=fake_inner_get_response, ) as mock_inner_get_response: response = await chat_client_base.get_response( - [Message(role="user", text="hello")], + [Message(role="user", contents=["hello"])], client_kwargs={"middleware": [inspecting_middleware], "trace_id": "trace-123"}, ) @@ -350,7 +350,7 @@ class TestChatMiddleware: chat_client_base.chat_middleware = [kwargs_middleware] # Execute chat client with runtime options - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] response = await chat_client_base.get_response( messages, options={"temperature": 0.7, "max_tokens": 100, "custom_param": "test_value"}, @@ -493,12 +493,12 @@ class TestChatMiddleware: ] ) final_response = ChatResponse( - messages=[Message(role="assistant", text="Based on the weather data, it's sunny!")] + messages=[Message(role="assistant", contents=["Based on the weather data, it's sunny!"])] ) client.run_responses = [function_call_response, final_response] # Execute the chat client directly with tools - this should trigger function invocation and middleware - messages = [Message(role="user", text="What's the weather in San Francisco?")] + messages = [Message(role="user", contents=["What's the weather in San Francisco?"])] response = await client.get_response(messages, options={"tools": [sample_tool_wrapped]}) # Verify response @@ -557,7 +557,7 @@ class TestChatMiddleware: client.run_responses = [function_call_response] # Execute the chat client directly with run-level middleware and tools - messages = [Message(role="user", text="What's the weather in New York?")] + messages = [Message(role="user", contents=["What's the weather in New York?"])] response = await client.get_response( messages, options={"tools": [sample_tool_wrapped]}, @@ -627,11 +627,11 @@ class TestChatMiddleware: ) ] ), - ChatResponse(messages=[Message(role="assistant", text="Based on the weather data, it's sunny!")]), + ChatResponse(messages=[Message(role="assistant", contents=["Based on the weather data, it's sunny!"])]), ] response = await client.get_response( - [Message(role="user", text="What's the weather in Seattle?")], + [Message(role="user", contents=["What's the weather in Seattle?"])], options={"tools": [sample_tool_wrapped]}, client_kwargs={"middleware": [run_level_chat_middleware, run_level_function_middleware]}, ) @@ -710,7 +710,7 @@ class TestChatMiddleware: updates: list[ChatResponseUpdate] = [] async for update in client.get_response( - [Message(role="user", text="What's the weather in Seattle?")], + [Message(role="user", contents=["What's the weather in Seattle?"])], options={"tools": [sample_tool_wrapped]}, client_kwargs={"middleware": [run_level_chat_middleware, run_level_function_middleware]}, stream=True, diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 332ee2b6e6..85091a71df 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -191,9 +191,7 @@ def mock_chat_client(): yield ChatResponseUpdate(contents=[Content.from_text(" world")], role="assistant", finish_reason="stop") def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=options.get("response_format")) return ResponseStream(_stream(), finalizer=_finalize) @@ -205,9 +203,9 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo """Test that when diagnostics are enabled, telemetry is applied.""" client = mock_chat_client() - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -222,17 +220,34 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None +@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) +async def test_chat_client_observability_accepts_model_option( + mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data +): + """Test that telemetry also captures the modern model option.""" + client = mock_chat_client() + + messages = [Message(role="user", contents=["Test message"])] + span_exporter.clear() + response = await client.get_response(messages=messages, options={"model": "Test"}) + assert response is not None + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.attributes[OtelAttr.REQUEST_MODEL] == "Test" + + @pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) async def test_chat_client_streaming_observability( mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data ): """Test streaming telemetry through the chat telemetry mixin.""" client = mock_chat_client() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() # Collect all yielded updates updates = [] - stream = client.get_response(stream=True, messages=messages, options={"model_id": "Test"}) + stream = client.get_response(stream=True, messages=messages, options={"model": "Test"}) async for update in stream: updates.append(update) await stream.get_final_response() @@ -259,8 +274,8 @@ async def test_chat_client_observability_with_instructions( client = mock_chat_client() - messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test", "instructions": "You are a helpful assistant."} + messages = [Message(role="user", contents=["Test message"])] + options = {"model": "Test", "instructions": "You are a helpful assistant."} span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -288,8 +303,8 @@ async def test_chat_client_streaming_observability_with_instructions( import json client = mock_chat_client() - messages = [Message(role="user", text="Test")] - options = {"model_id": "Test", "instructions": "You are a helpful assistant."} + messages = [Message(role="user", contents=["Test"])] + options = {"model": "Test", "instructions": "You are a helpful assistant."} span_exporter.clear() updates = [] @@ -317,8 +332,8 @@ async def test_chat_client_observability_without_instructions( """Test that system_instructions attribute is not set when instructions are not provided.""" client = mock_chat_client() - messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test"} # No instructions + messages = [Message(role="user", contents=["Test message"])] + options = {"model": "Test"} # No instructions span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -338,8 +353,8 @@ async def test_chat_client_observability_with_empty_instructions( """Test that system_instructions attribute is not set when instructions is an empty string.""" client = mock_chat_client() - messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test", "instructions": ""} # Empty string + messages = [Message(role="user", contents=["Test message"])] + options = {"model": "Test", "instructions": ""} # Empty string span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -361,8 +376,8 @@ async def test_chat_client_observability_with_list_instructions( client = mock_chat_client() - messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test", "instructions": ["Instruction 1", "Instruction 2"]} + messages = [Message(role="user", contents=["Test message"])] + options = {"model": "Test", "instructions": ["Instruction 1", "Instruction 2"]} span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -379,10 +394,10 @@ async def test_chat_client_observability_with_list_instructions( assert system_instructions[1]["content"] == "Instruction 2" -async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter): - """Test telemetry shouldn't fail when the model_id is not provided for unknown reason.""" +async def test_chat_client_without_model_observability(mock_chat_client, span_exporter: InMemorySpanExporter): + """Test telemetry shouldn't fail when the model is not provided for unknown reason.""" client = mock_chat_client() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() response = await client.get_response(messages=messages) @@ -396,12 +411,10 @@ async def test_chat_client_without_model_id_observability(mock_chat_client, span assert span.attributes[OtelAttr.REQUEST_MODEL] == "unknown" -async def test_chat_client_streaming_without_model_id_observability( - mock_chat_client, span_exporter: InMemorySpanExporter -): - """Test streaming telemetry shouldn't fail when the model_id is not provided for unknown reason.""" +async def test_chat_client_streaming_without_model_observability(mock_chat_client, span_exporter: InMemorySpanExporter): + """Test streaming telemetry shouldn't fail when the model is not provided for unknown reason.""" client = mock_chat_client() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() # Collect all yielded updates updates = [] @@ -441,7 +454,7 @@ def mock_chat_agent(): self.id = "test_agent_id" self.name = "test_agent" self.description = "Test agent description" - self.default_options: dict[str, Any] = {"model_id": "TestModel"} + self.default_options: dict[str, Any] = {"model": "TestModel"} def run(self, messages=None, *, session=None, stream=False, **kwargs): if stream: @@ -1536,11 +1549,11 @@ async def test_chat_client_observability_exception(mock_chat_client, span_export raise ValueError("Test error") client = FailingChatClient() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() with pytest.raises(ValueError, match="Test error"): - await client.get_response(messages=messages, options={"model_id": "Test"}) + await client.get_response(messages=messages, options={"model": "Test"}) spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -1566,11 +1579,11 @@ async def test_chat_client_streaming_observability_exception(mock_chat_client, s return ResponseStream(_stream(), finalizer=ChatResponse.from_updates) client = FailingStreamingChatClient() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() with pytest.raises(ValueError, match="Streaming error"): - async for _ in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): + async for _ in client.get_response(messages=messages, stream=True, options={"model": "Test"}): pass spans = span_exporter.get_finished_spans() @@ -1651,8 +1664,8 @@ def test_get_response_attributes_with_finish_reason(): assert OtelAttr.FINISH_REASONS in result -def test_get_response_attributes_with_model_id(): - """Test _get_response_attributes includes model_id.""" +def test_get_response_attributes_with_model(): + """Test _get_response_attributes includes model.""" from unittest.mock import Mock from agent_framework.observability import _get_response_attributes @@ -1662,7 +1675,7 @@ def test_get_response_attributes_with_model_id(): response.finish_reason = None response.raw_representation = None response.usage_details = None - response.model_id = "gpt-4" + response.model = "gpt-4" attrs = {} result = _get_response_attributes(attrs, response) @@ -2066,16 +2079,16 @@ async def test_capture_messages_with_finish_reason(mock_chat_client, span_export class ClientWithFinishReason(mock_chat_client): async def _inner_get_response(self, *, messages, options, **kwargs): return ChatResponse( - messages=[Message(role="assistant", text="Done")], + messages=[Message(role="assistant", contents=["Done"])], usage_details=UsageDetails(input_token_count=5, output_token_count=10), finish_reason="stop", ) client = ClientWithFinishReason() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None assert response.finish_reason == "stop" @@ -2162,10 +2175,10 @@ async def test_agent_streaming_exception(span_exporter: InMemorySpanExporter, en async def test_chat_client_when_disabled(mock_chat_client, span_exporter: InMemorySpanExporter): """Test that no spans are created when instrumentation is disabled.""" client = mock_chat_client() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() @@ -2177,11 +2190,11 @@ async def test_chat_client_when_disabled(mock_chat_client, span_exporter: InMemo async def test_chat_client_streaming_when_disabled(mock_chat_client, span_exporter: InMemorySpanExporter): """Test streaming creates no spans when instrumentation is disabled.""" client = mock_chat_client() - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] span_exporter.clear() updates = [] - async for update in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): + async for update in client.get_response(messages=messages, stream=True, options={"model": "Test"}): updates.append(update) assert len(updates) == 2 # Still works functionally @@ -2501,7 +2514,7 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: def __init__(self): super().__init__() self.call_count = 0 - self.model_id = "test-model" + self.model = "test-model" def service_url(self): return "https://test.example.com" @@ -2527,7 +2540,7 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: ], ) return ChatResponse( - messages=[Message(role="assistant", text="The weather in Seattle is sunny!")], + messages=[Message(role="assistant", contents=["The weather in Seattle is sunny!"])], ) return _get() @@ -2536,7 +2549,7 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: span_exporter.clear() response = await client.get_response( - messages=[Message(role="user", text="What's the weather in Seattle?")], + messages=[Message(role="user", contents=["What's the weather in Seattle?"])], options={"tools": [get_weather], "tool_choice": "auto"}, ) @@ -2585,7 +2598,7 @@ async def test_agent_and_chat_spans_do_not_duplicate_response_telemetry( def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: return ChatResponse( - messages=[Message(role="assistant", text="Nested response")], + messages=[Message(role="assistant", contents=["Nested response"])], response_id="nested_resp_123", usage_details=UsageDetails(input_token_count=11, output_token_count=22), finish_reason="stop", @@ -2595,7 +2608,7 @@ async def test_agent_and_chat_spans_do_not_duplicate_response_telemetry( async def _get() -> ChatResponse: return ChatResponse( - messages=[Message(role="assistant", text="Nested response")], + messages=[Message(role="assistant", contents=["Nested response"])], response_id="nested_resp_123", usage_details=UsageDetails(input_token_count=11, output_token_count=22), finish_reason="stop", @@ -2608,7 +2621,7 @@ async def test_agent_and_chat_spans_do_not_duplicate_response_telemetry( id="nested_agent_id", name="nested_agent", description="Nested telemetry agent", - default_options={"model_id": "NestedModel"}, + default_options={"model": "NestedModel"}, ) span_exporter.clear() @@ -2653,15 +2666,15 @@ async def test_capture_messages_preserves_non_ascii_characters(mock_chat_client, class ClientWithJapanese(mock_chat_client): async def _inner_get_response(self, *, messages, options, **kwargs): return ChatResponse( - messages=[Message(role="assistant", text=japanese_text)], + messages=[Message(role="assistant", contents=[japanese_text])], usage_details=UsageDetails(input_token_count=5, output_token_count=10), ) client = ClientWithJapanese() - messages = [Message(role="user", text=japanese_text)] + messages = [Message(role="user", contents=[japanese_text])] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() @@ -2702,7 +2715,7 @@ async def test_system_instructions_preserves_non_ascii_characters(span_exporter: _capture_messages( span=span, provider_name="test_provider", - messages=[Message(role="user", text="Test")], + messages=[Message(role="user", contents=["Test"])], system_instructions=chinese_text, ) @@ -2825,9 +2838,9 @@ async def test_agent_instructions_from_default_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default system instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default system instructions."} - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] span_exporter.clear() response = await agent.run(messages) @@ -2851,9 +2864,9 @@ async def test_agent_instructions_from_options_override( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel"} # No default instructions + agent.default_options = {"model": "TestModel"} # No default instructions - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] span_exporter.clear() response = await agent.run(messages, options={"instructions": "Override instructions."}) @@ -2876,9 +2889,9 @@ async def test_agent_instructions_merged_from_default_and_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default instructions."} - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] span_exporter.clear() response = await agent.run(messages, options={"instructions": "Additional instructions."}) @@ -2903,9 +2916,9 @@ async def test_agent_streaming_instructions_from_default_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default streaming instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default streaming instructions."} - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] span_exporter.clear() updates = [] stream = agent.run(messages, stream=True) @@ -2932,9 +2945,9 @@ async def test_agent_streaming_instructions_merged_from_default_and_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default instructions."} - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] span_exporter.clear() updates = [] stream = agent.run(messages, stream=True, options={"instructions": "Stream override."}) @@ -2960,9 +2973,9 @@ async def test_agent_no_instructions_in_default_or_options( ): """Test that system_instructions is not set when neither default_options nor options have instructions.""" agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel"} # No instructions + agent.default_options = {"model": "TestModel"} # No instructions - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] span_exporter.clear() response = await agent.run(messages) @@ -3191,7 +3204,7 @@ async def test_agent_invoke_span_aggregates_usage_across_tool_calls(span_exporte usage_details=UsageDetails(input_token_count=2239, output_token_count=192), ), ChatResponse( - messages=Message(role="assistant", text="The weather in Seattle is sunny."), + messages=Message(role="assistant", contents=["The weather in Seattle is sunny."]), usage_details=UsageDetails(input_token_count=2569, output_token_count=99), ), ] @@ -3235,7 +3248,7 @@ async def test_agent_invoke_span_usage_single_call(span_exporter: InMemorySpanEx client = MockBaseChatClient() client.run_responses = [ ChatResponse( - messages=Message(role="assistant", text="Hello!"), + messages=Message(role="assistant", contents=["Hello!"]), usage_details=UsageDetails(input_token_count=100, output_token_count=50), ), ] @@ -3278,7 +3291,7 @@ async def test_agent_invoke_span_aggregates_usage_on_max_iterations_exhaustion(s ), # Exhaustion path: consumed by tool_choice="none" final call (mock ignores usage) ChatResponse( - messages=Message(role="assistant", text="placeholder"), + messages=Message(role="assistant", contents=["placeholder"]), usage_details=UsageDetails(input_token_count=300, output_token_count=60), ), ] diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index bd2cb8155e..e5eacebfe5 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -1,16 +1,24 @@ # Copyright (c) Microsoft. All rights reserved. import json -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence -from agent_framework import Message -from agent_framework._sessions import ( +import pytest + +from agent_framework import ( + AgentContext, AgentSession, - BaseContextProvider, - BaseHistoryProvider, + ChatContext, + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, + Message, SessionContext, + agent_middleware, + chat_middleware, ) +from agent_framework._sessions import LOCAL_HISTORY_CONVERSATION_ID, is_local_history_conversation_id +from agent_framework.exceptions import MiddlewareException # --------------------------------------------------------------------------- # SessionContext tests @@ -102,6 +110,50 @@ class TestSessionContext: ctx.extend_instructions("sys", ["Be helpful", "Be concise"]) assert ctx.instructions == ["Be helpful", "Be concise"] + def test_extend_middleware_creates_key_and_appends(self) -> None: + ctx = SessionContext(input_messages=[]) + + @chat_middleware + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @chat_middleware + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + ctx.extend_middleware("rag", first_middleware) + ctx.extend_middleware("rag", [second_middleware]) + + assert ctx.middleware["rag"] == [first_middleware, second_middleware] + assert ctx.get_middleware() == [first_middleware, second_middleware] + + def test_extend_middleware_preserves_source_order(self) -> None: + ctx = SessionContext(input_messages=[]) + + @chat_middleware + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @chat_middleware + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + ctx.extend_middleware("a", first_middleware) + ctx.extend_middleware("b", second_middleware) + + assert list(ctx.middleware.keys()) == ["a", "b"] + assert ctx.get_middleware() == [first_middleware, second_middleware] + + def test_extend_middleware_rejects_agent_middleware(self) -> None: + ctx = SessionContext(input_messages=[]) + + @agent_middleware + async def provider_agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + with pytest.raises(MiddlewareException, match="Context providers may only add chat or function middleware"): + ctx.extend_middleware("rag", provider_agent_middleware) + def test_get_messages_all(self) -> None: ctx = SessionContext(input_messages=[]) ctx.extend_messages("a", [Message(role="user", contents=["a"])]) @@ -154,37 +206,41 @@ class TestSessionContext: ctx._response = resp assert ctx.response is resp + def test_local_history_conversation_id_sentinel(self) -> None: + assert is_local_history_conversation_id(LOCAL_HISTORY_CONVERSATION_ID) is True + assert is_local_history_conversation_id("some_other_id") is False + # --------------------------------------------------------------------------- -# BaseContextProvider tests +# ContextProvider tests # --------------------------------------------------------------------------- -class TestContextProviderBase: +class TestContextProvider: def test_source_id_required(self) -> None: - provider = BaseContextProvider(source_id="test") + provider = ContextProvider(source_id="test") assert provider.source_id == "test" async def test_before_run_is_noop(self) -> None: - provider = BaseContextProvider(source_id="test") + provider = ContextProvider(source_id="test") session = AgentSession() ctx = SessionContext(input_messages=[]) # Should not raise await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] async def test_after_run_is_noop(self) -> None: - provider = BaseContextProvider(source_id="test") + provider = ContextProvider(source_id="test") session = AgentSession() ctx = SessionContext(input_messages=[]) await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] # --------------------------------------------------------------------------- -# BaseHistoryProvider tests +# HistoryProvider tests # --------------------------------------------------------------------------- -class ConcreteHistoryProvider(BaseHistoryProvider): +class ConcreteHistoryProvider(HistoryProvider): """Concrete test implementation.""" def __init__(self, source_id: str, stored_messages: list[Message] | None = None, **kwargs) -> None: diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 134c4219cd..16cae57dcd 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -11,7 +11,7 @@ from unittest.mock import AsyncMock import pytest -from agent_framework import SessionContext, Skill, SkillResource, SkillsProvider +from agent_framework import SessionContext, Skill, SkillResource, SkillScript, SkillScriptRunner, SkillsProvider from agent_framework._skills import ( DEFAULT_RESOURCE_EXTENSIONS, DEFAULT_SCRIPT_EXTENSIONS, @@ -32,6 +32,8 @@ from agent_framework._skills import ( _validate_skill_metadata, ) +pytestmark = pytest.mark.filterwarnings(r"ignore:\[SKILLS\].*:FutureWarning") + async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None: """No-op script runner for tests that need a SkillScriptRunner.""" @@ -778,6 +780,44 @@ class TestSymlinkDetection: # --------------------------------------------------------------------------- +class TestSkillsExperimentalStage: + """Tests for the experimental stage annotations applied to skills APIs.""" + + def test_docstrings_include_experimental_warning(self) -> None: + assert SkillResource.__doc__ is not None + assert SkillScript.__doc__ is not None + assert Skill.__doc__ is not None + assert SkillScriptRunner.__doc__ is not None + assert SkillsProvider.__doc__ is not None + assert SkillScript.parameters_schema.__doc__ is not None + + assert ".. warning:: Experimental" in SkillResource.__doc__ + assert ".. warning:: Experimental" in SkillScript.__doc__ + assert ".. warning:: Experimental" in Skill.__doc__ + assert ".. warning:: Experimental" in SkillScriptRunner.__doc__ + assert ".. warning:: Experimental" in SkillsProvider.__doc__ + assert ".. warning:: Experimental" not in SkillScript.parameters_schema.__doc__ + + def test_feature_metadata_is_set(self) -> None: + assert SkillResource.__feature_stage__ == "experimental" + assert SkillScript.__feature_stage__ == "experimental" + assert Skill.__feature_stage__ == "experimental" + assert SkillsProvider.__feature_stage__ == "experimental" + feature_ids = [ + SkillResource.__feature_id__, + SkillScript.__feature_id__, + Skill.__feature_id__, + SkillsProvider.__feature_id__, + ] + assert all(isinstance(feature_id, str) and feature_id for feature_id in feature_ids) + assert len(set(feature_ids)) == 1 + assert getattr(SkillScriptRunner, "__feature_stage__", None) is None + assert getattr(SkillScriptRunner, "__feature_id__", None) is None + assert SkillScript.parameters_schema.fget is not None + assert not hasattr(SkillScript.parameters_schema.fget, "__feature_stage__") + assert not hasattr(SkillScript.parameters_schema.fget, "__feature_id__") + + class TestSkillResource: """Tests for SkillResource dataclass.""" @@ -1839,40 +1879,28 @@ class TestSkillScript: """Tests for the SkillScript data model.""" def test_empty_name_raises(self) -> None: - from agent_framework import SkillScript - with pytest.raises(ValueError, match="Script name cannot be empty"): SkillScript(name="") def test_whitespace_name_raises(self) -> None: - from agent_framework import SkillScript - with pytest.raises(ValueError, match="Script name cannot be empty"): SkillScript(name=" ") def test_path_default_none(self) -> None: - from agent_framework import SkillScript - script = SkillScript(name="test", function=lambda: None) assert script.path is None def test_path_set_explicitly(self) -> None: - from agent_framework import SkillScript - script = SkillScript(name="gen.py", path="/skills/my-skill/scripts/gen.py") assert script.path == "/skills/my-skill/scripts/gen.py" def test_create_with_function(self) -> None: - from agent_framework import SkillScript - script = SkillScript(name="analyze", description="Run analysis", function=lambda: "result") assert script.name == "analyze" assert script.description == "Run analysis" assert script.function is not None def test_accepts_kwargs_true_for_kwargs_function(self) -> None: - from agent_framework import SkillScript - def func_with_kwargs(**kwargs: Any) -> str: return "result" @@ -1880,8 +1908,6 @@ class TestSkillScript: assert script._accepts_kwargs is True def test_accepts_kwargs_false_for_regular_function(self) -> None: - from agent_framework import SkillScript - def func_no_kwargs(x: int = 0) -> str: return "result" diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 0f87219690..143aa95727 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -608,7 +608,7 @@ async def test_tool_invoke_rejects_unexpected_runtime_kwargs() -> None: await simple_tool.invoke( arguments=args, api_token="secret-token", - options={"model_id": "dummy"}, + options={"model": "dummy"}, ) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 8e5a0dc7d8..23c7dd8cd6 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -43,7 +43,7 @@ from agent_framework._types import ( add_usage_details, validate_tool_mode, ) -from agent_framework.exceptions import ContentError +from agent_framework.exceptions import AdditionItemMismatch, ContentError @fixture @@ -699,7 +699,7 @@ def test_ai_content_serialization(args: dict): def test_chat_message_text(): """Test the Message class to ensure it initializes correctly with text content.""" # Create a Message with a role and text content - message = Message(role="user", text="Hello, how are you?") + message = Message(role="user", contents=["Hello, how are you?"]) # Check the type and content assert message.role == "user" @@ -730,7 +730,7 @@ def test_chat_message_contents(): def test_chat_message_with_chatrole_instance(): - m = Message(role="user", text="hi") + m = Message(role="user", contents=["hi"]) assert m.role == "user" assert m.text == "hi" @@ -741,7 +741,7 @@ def test_chat_message_with_chatrole_instance(): def test_chat_response(): """Test the ChatResponse class to ensure it initializes correctly with a message.""" # Create a Message - message = Message(role="assistant", text="I'm doing well, thank you!") + message = Message(role="assistant", contents=["I'm doing well, thank you!"]) # Create a ChatResponse with the message response = ChatResponse(messages=message) @@ -754,6 +754,14 @@ def test_chat_response(): assert str(response) == response.text +def test_chat_response_accepts_model_alias() -> None: + """Test ChatResponse accepts model and exposes it through model alias.""" + response = ChatResponse(messages=Message(role="assistant", contents=["Hello"]), model="claude-test") + + assert response.model == "claude-test" + assert response.model == "claude-test" + + class OutputModel(BaseModel): response: str @@ -761,7 +769,7 @@ class OutputModel(BaseModel): def test_chat_response_with_format(): """Test the ChatResponse class to ensure it initializes correctly with a message.""" # Create a Message - message = Message(role="assistant", text='{"response": "Hello"}') + message = Message(role="assistant", contents=['{"response": "Hello"}']) # Create a ChatResponse with the message response = ChatResponse(messages=message, response_format=OutputModel) @@ -778,7 +786,7 @@ def test_chat_response_with_format(): def test_chat_response_with_format_init(): """Test the ChatResponse class to ensure it initializes correctly with a message.""" # Create a Message - message = Message(role="assistant", text='{"response": "Hello"}') + message = Message(role="assistant", contents=['{"response": "Hello"}']) # Create a ChatResponse with the message response = ChatResponse(messages=message, response_format=OutputModel) @@ -792,6 +800,19 @@ def test_chat_response_with_format_init(): assert response.value.response == "Hello" +def test_chat_response_with_mapping_response_format() -> None: + """ChatResponse.value should parse JSON when response_format is a mapping.""" + message = Message(role="assistant", contents=['{"response": "Hello"}']) + response = ChatResponse( + messages=message, + response_format={"type": "object", "properties": {"response": {"type": "string"}}}, + ) + + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["response"] == "Hello" + + def test_chat_response_value_raises_on_invalid_schema(): """Test that value property raises ValidationError with field constraint details.""" @@ -800,7 +821,7 @@ def test_chat_response_value_raises_on_invalid_schema(): name: str = Field(min_length=10) score: int = Field(gt=0, le=100) - message = Message(role="assistant", text='{"id": 1, "name": "test", "score": -5}') + message = Message(role="assistant", contents=['{"id": 1, "name": "test", "score": -5}']) response = ChatResponse(messages=message, response_format=StrictSchema) with raises(ValidationError) as exc_info: @@ -821,7 +842,7 @@ def test_agent_response_value_raises_on_invalid_schema(): name: str = Field(min_length=10) score: int = Field(gt=0, le=100) - message = Message(role="assistant", text='{"id": 1, "name": "test", "score": -5}') + message = Message(role="assistant", contents=['{"id": 1, "name": "test", "score": -5}']) response = AgentResponse(messages=message, response_format=StrictSchema) with raises(ValidationError) as exc_info: @@ -851,6 +872,14 @@ def test_chat_response_update(): assert response_update.text == "I'm doing well, thank you!" +def test_chat_response_update_accepts_model_alias() -> None: + """Test ChatResponseUpdate accepts model and exposes it through model alias.""" + response_update = ChatResponseUpdate(contents=[Content.from_text("Hello")], model="claude-test") + + assert response_update.model == "claude-test" + assert response_update.model == "claude-test" + + def test_chat_response_updates_to_chat_response_one(): """Test converting ChatResponseUpdate to ChatResponse.""" # Create a Message @@ -988,6 +1017,22 @@ async def test_chat_response_from_async_generator_output_format_in_method(): assert resp.value.response == "Hello" +async def test_chat_response_from_async_generator_mapping_response_format() -> None: + async def gen() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[Content.from_text('{ "respon')], message_id="1") + yield ChatResponseUpdate(contents=[Content.from_text('se": "Hello" }')], message_id="1") + + resp = await ChatResponse.from_update_generator( + gen(), + output_format_type={"type": "object", "properties": {"response": {"type": "string"}}}, + ) + + assert resp.text == '{ "response": "Hello" }' + assert resp.value is not None + assert isinstance(resp.value, dict) + assert resp.value["response"] == "Hello" + + # region ToolMode @@ -1140,7 +1185,7 @@ def test_chat_options_and_tool_choice_required_specific_function() -> None: @fixture def chat_message() -> Message: - return Message(role="user", text="Hello") + return Message(role="user", contents=["Hello"]) @fixture @@ -1257,7 +1302,7 @@ def test_agent_run_response_created_at() -> None: # Test with a properly formatted UTC timestamp utc_timestamp = "2024-12-01T00:31:30.000000Z" response = AgentResponse( - messages=[Message(role="assistant", text="Hello")], + messages=[Message(role="assistant", contents=["Hello"])], created_at=utc_timestamp, ) assert response.created_at == utc_timestamp @@ -1267,7 +1312,7 @@ def test_agent_run_response_created_at() -> None: now_utc = datetime.now(tz=timezone.utc) formatted_utc = now_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ") response_with_now = AgentResponse( - messages=[Message(role="assistant", text="Hello")], + messages=[Message(role="assistant", contents=["Hello"])], created_at=formatted_utc, ) assert response_with_now.created_at == formatted_utc @@ -1374,7 +1419,7 @@ def test_response_update_propagates_fields_and_metadata(): response_id="rid", message_id="mid", conversation_id="cid", - model_id="model-x", + model="model-x", created_at="t0", finish_reason="stop", additional_properties={"k": "v"}, @@ -1383,7 +1428,7 @@ def test_response_update_propagates_fields_and_metadata(): assert resp.response_id == "rid" assert resp.created_at == "t0" assert resp.conversation_id == "cid" - assert resp.model_id == "model-x" + assert resp.model == "model-x" assert resp.finish_reason == "stop" assert resp.additional_properties and resp.additional_properties["k"] == "v" assert resp.messages[0].role == "assistant" @@ -1421,7 +1466,7 @@ def test_chat_tool_mode_eq_with_string(): @fixture def agent_run_response_async() -> AgentResponse: - return AgentResponse(messages=[Message(role="user", text="Hello")]) + return AgentResponse(messages=[Message(role="user", contents=["Hello"])]) async def test_agent_run_response_from_async_generator(): @@ -1526,6 +1571,88 @@ def test_text_reasoning_content_iadd_coverage(): assert t1.text == "Thinking 1 Thinking 2" +def test_text_reasoning_content_add_preserves_id(): + """Test that coalescing text_reasoning Content preserves the id field.""" + + t1 = Content.from_text_reasoning(id="rs_abc123", text="Thinking part 1") + t2 = Content.from_text_reasoning(id="rs_abc123", text=" part 2") + + result = t1 + t2 + assert result.text == "Thinking part 1 part 2" + assert result.id == "rs_abc123" + + +def test_text_reasoning_content_add_id_fallback_to_other(): + """Test that coalescing falls back to other's id when self has no id.""" + + t1 = Content.from_text_reasoning(text="Thinking part 1") + t2 = Content.from_text_reasoning(id="rs_abc123", text=" part 2") + + result = t1 + t2 + assert result.id == "rs_abc123" + + +def test_text_reasoning_content_add_preserves_id_with_encrypted_content(): + """Test that id and encrypted_content both survive coalescing for round-trip.""" + + t1 = Content.from_text_reasoning( + id="rs_abc123", + text="Thinking", + additional_properties={"encrypted_content": "enc_blob_data"}, + ) + t2 = Content.from_text_reasoning(id="rs_abc123", text=" more") + + result = t1 + t2 + assert result.text == "Thinking more" + assert result.id == "rs_abc123" + assert result.additional_properties.get("encrypted_content") == "enc_blob_data" + + +def test_text_reasoning_content_add_conflicting_ids_raises(): + """Test that coalescing text_reasoning Content with different ids raises AdditionItemMismatch.""" + + t1 = Content.from_text_reasoning(id="rs_abc123", text="Thinking part 1") + t2 = Content.from_text_reasoning(id="rs_xyz789", text=" part 2") + + with pytest.raises(AdditionItemMismatch, match="different ids"): + t1 + t2 + + +def test_text_reasoning_content_add_neither_has_id(): + """Test that coalescing text_reasoning Content when neither has an id results in None id.""" + + t1 = Content.from_text_reasoning(text="Thinking part 1") + t2 = Content.from_text_reasoning(text=" part 2") + + result = t1 + t2 + assert result.text == "Thinking part 1 part 2" + assert result.id is None + + +def test_coalesce_text_reasoning_with_different_ids(): + """Test that _coalesce_text_content keeps separate text_reasoning items when IDs differ. + + Regression test: streaming responses can produce multiple text_reasoning + segments with distinct IDs. These must not be merged into one. + """ + from agent_framework._types import _coalesce_text_content + + contents = [ + Content.from_text_reasoning(id="rs_aaa", text="Thinking A1"), + Content.from_text_reasoning(id="rs_aaa", text=" A2"), + Content.from_text_reasoning(id="rs_bbb", text="Thinking B1"), + Content.from_text_reasoning(id="rs_bbb", text=" B2"), + ] + + _coalesce_text_content(contents, "text_reasoning") + + assert len(contents) == 2 + assert contents[0].id == "rs_aaa" + assert contents[0].text == "Thinking A1 A2" + assert contents[1].id == "rs_bbb" + assert contents[1].text == "Thinking B1 B2" + + def test_comprehensive_to_dict_exclude_options(): """Test to_dict methods with various exclude options for better coverage.""" @@ -1853,7 +1980,7 @@ def test_chat_response_complex_serialization(): assert isinstance(response.messages[0], Message) assert isinstance(response.finish_reason, str) # FinishReason is now a NewType of str assert isinstance(response.usage_details, dict) - assert response.model_id == "gpt-4" # Should be stored as model_id + assert response.model == "gpt-4" # Should be stored as model # Test to_dict with complex objects response_dict = response.to_dict() @@ -1861,7 +1988,7 @@ def test_chat_response_complex_serialization(): assert isinstance(response_dict["messages"][0], dict) assert isinstance(response_dict["finish_reason"], str) # FinishReason serializes to string assert isinstance(response_dict["usage_details"], dict) - assert response_dict["model"] == "gpt-4" # Should serialize as model_id + assert response_dict["model"] == "gpt-4" # Should serialize as model def test_chat_response_update_all_content_types(): @@ -3941,3 +4068,87 @@ def test_oauth_consent_request_serialization_roundtrip(): # endregion + + +# region prepend_instructions_to_messages tests + + +def test_prepend_instructions_basic(): + """Test that instructions are prepended as system message.""" + from agent_framework._types import prepend_instructions_to_messages + + messages = [Message("user", ["Hello"])] + result = prepend_instructions_to_messages(messages, "You are helpful.") + assert len(result) == 2 + assert result[0].role == "system" + assert result[0].text == "You are helpful." + assert result[1].role == "user" + + +def test_prepend_instructions_none(): + """Test that None instructions returns messages unchanged.""" + from agent_framework._types import prepend_instructions_to_messages + + messages = [Message("user", ["Hello"])] + result = prepend_instructions_to_messages(messages, None) + assert result is messages + + +def test_prepend_instructions_skips_duplicate(): + """Test that duplicate system instructions are not prepended again.""" + from agent_framework._types import prepend_instructions_to_messages + + messages = [ + Message("system", ["You are helpful."]), + Message("user", ["Hello"]), + ] + result = prepend_instructions_to_messages(messages, "You are helpful.") + assert len(result) == 2 + assert result[0].role == "system" + assert result[0].text == "You are helpful." + assert result[1].role == "user" + + +def test_prepend_instructions_skips_duplicate_list(): + """Test deduplication with a list of instructions.""" + from agent_framework._types import prepend_instructions_to_messages + + messages = [ + Message("system", ["First instruction"]), + Message("system", ["Second instruction"]), + Message("user", ["Hello"]), + ] + result = prepend_instructions_to_messages(messages, ["First instruction", "Second instruction"]) + assert len(result) == 3 + assert result[0].text == "First instruction" + assert result[1].text == "Second instruction" + assert result[2].text == "Hello" + + +def test_prepend_instructions_adds_when_different(): + """Test that different instructions are still prepended.""" + from agent_framework._types import prepend_instructions_to_messages + + messages = [ + Message("system", ["Old instruction"]), + Message("user", ["Hello"]), + ] + result = prepend_instructions_to_messages(messages, "New instruction") + assert len(result) == 3 + assert result[0].role == "system" + assert result[0].text == "New instruction" + assert result[1].text == "Old instruction" + assert result[2].text == "Hello" + + +def test_prepend_instructions_custom_role(): + """Test prepending with a custom role.""" + from agent_framework._types import prepend_instructions_to_messages + + messages = [Message("user", ["Hello"])] + result = prepend_instructions_to_messages(messages, "Be concise.", role="developer") + assert len(result) == 2 + assert result[0].role == "developer" + + +# endregion diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 6298a8963d..5ffd60aa55 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -1,8 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. -import logging from collections.abc import AsyncIterable, Awaitable -from typing import TYPE_CHECKING, Any, Literal, overload +from typing import Any, Literal, overload import pytest @@ -22,9 +21,7 @@ from agent_framework import ( ) from agent_framework._workflows._agent_executor import AgentExecutorResponse from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage - -if TYPE_CHECKING: - from _pytest.logging import LogCaptureFixture +from agent_framework._workflows._const import GLOBAL_KWARGS_KEY class _CountingAgent(BaseAgent): @@ -161,8 +158,8 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: # Add some initial messages to the session state to verify session state persistence initial_messages = [ - Message(role="user", text="Initial message 1"), - Message(role="assistant", text="Initial response 1"), + Message(role="user", contents=["Initial message 1"]), + Message(role="assistant", contents=["Initial response 1"]), ] initial_session.state["history"] = {"messages": initial_messages} @@ -259,9 +256,9 @@ async def test_agent_executor_save_and_restore_state_directly() -> None: # Add messages to session state session_messages = [ - Message(role="user", text="Message in session 1"), - Message(role="assistant", text="Session response 1"), - Message(role="user", text="Message in session 2"), + Message(role="user", contents=["Message in session 1"]), + Message(role="assistant", contents=["Session response 1"]), + Message(role="user", contents=["Message in session 2"]), ] session.state["history"] = {"messages": session_messages} @@ -269,8 +266,8 @@ async def test_agent_executor_save_and_restore_state_directly() -> None: # Add messages to executor cache cache_messages = [ - Message(role="user", text="Cached user message"), - Message(role="assistant", text="Cached assistant response"), + Message(role="user", contents=["Cached user message"]), + Message(role="assistant", contents=["Cached assistant response"]), ] executor._cache = list(cache_messages) # type: ignore[reportPrivateUsage] @@ -309,87 +306,28 @@ async def test_agent_executor_save_and_restore_state_directly() -> None: assert restored_session.session_id == session.session_id -async def test_agent_executor_run_with_session_kwarg_does_not_raise() -> None: - """Passing session= via workflow.run() should not cause a duplicate-keyword TypeError (#4295).""" - agent = _CountingAgent(id="session_kwarg_agent", name="SessionKwargAgent") - executor = AgentExecutor(agent, id="session_kwarg_exec") - workflow = WorkflowBuilder(start_executor=executor).build() +async def test_prepare_agent_run_args_extracts_invocation_kwargs() -> None: + """_prepare_agent_run_args extracts function_invocation_kwargs and client_kwargs.""" + agent = _CountingAgent(id="test_agent", name="TestAgent") + executor = AgentExecutor(agent, id="test_exec") - # This previously raised: TypeError: run() got multiple values for keyword argument 'session' - result = await workflow.run("hello", session="user-supplied-value") - assert result is not None - assert agent.call_count == 1 - - -async def test_agent_executor_run_streaming_with_stream_kwarg_does_not_raise() -> None: - """Passing stream= via workflow.run() kwargs should not cause a duplicate-keyword TypeError.""" - agent = _CountingAgent(id="stream_kwarg_agent", name="StreamKwargAgent") - executor = AgentExecutor(agent, id="stream_kwarg_exec") - workflow = WorkflowBuilder(start_executor=executor).build() - - # stream=True at workflow level triggers streaming mode (returns async iterable) - events: list[WorkflowEvent] = [] - async for event in workflow.run("hello", stream=True): - events.append(event) - assert len(events) > 0 - assert agent.call_count == 1 - - -@pytest.mark.parametrize("reserved_kwarg", ["session", "stream", "messages"]) -async def test_prepare_agent_run_args_strips_reserved_kwargs(reserved_kwarg: str, caplog: "LogCaptureFixture") -> None: - """_prepare_agent_run_args must remove reserved kwargs and log a warning.""" raw: dict[str, Any] = { - reserved_kwarg: "should-be-stripped", - "custom_key": "keep-me", + "function_invocation_kwargs": {"__global__": {"key": "fi_val"}}, + "client_kwargs": {"__global__": {"key": "ci_val"}}, } - - with caplog.at_level(logging.WARNING): - run_kwargs, options = AgentExecutor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] - - assert reserved_kwarg not in run_kwargs - assert "custom_key" in run_kwargs - assert options is not None - assert options["additional_function_arguments"]["custom_key"] == "keep-me" - assert any(reserved_kwarg in record.message for record in caplog.records) + fi_kwargs, ci_kwargs = executor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] + assert fi_kwargs == {"key": "fi_val"} + assert ci_kwargs == {"key": "ci_val"} -async def test_prepare_agent_run_args_preserves_non_reserved_kwargs() -> None: - """Non-reserved workflow kwargs should pass through unchanged.""" - raw: dict[str, Any] = {"custom_param": "value", "another": 42} - run_kwargs, _options = AgentExecutor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] - assert run_kwargs["custom_param"] == "value" - assert run_kwargs["another"] == 42 +async def test_prepare_agent_run_args_returns_none_when_no_kwargs() -> None: + """_prepare_agent_run_args returns None for both when raw dict has no invocation kwargs.""" + agent = _CountingAgent(id="test_agent", name="TestAgent") + executor = AgentExecutor(agent, id="test_exec") - -async def test_prepare_agent_run_args_strips_all_reserved_kwargs_at_once( - caplog: "LogCaptureFixture", -) -> None: - """All reserved kwargs should be stripped when supplied together, each emitting a warning.""" - raw: dict[str, Any] = {"session": "x", "stream": True, "messages": [], "custom": 1} - - with caplog.at_level(logging.WARNING): - run_kwargs, options = AgentExecutor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] - - assert "session" not in run_kwargs - assert "stream" not in run_kwargs - assert "messages" not in run_kwargs - assert run_kwargs["custom"] == 1 - assert options is not None - assert options["additional_function_arguments"]["custom"] == 1 - - warned_keys = {r.message.split("'")[1] for r in caplog.records if "reserved" in r.message.lower()} - assert warned_keys == {"session", "stream", "messages"} - - -async def test_agent_executor_run_with_messages_kwarg_does_not_raise() -> None: - """Passing messages= via workflow.run() kwargs should not cause a duplicate-keyword TypeError.""" - agent = _CountingAgent(id="messages_kwarg_agent", name="MessagesKwargAgent") - executor = AgentExecutor(agent, id="messages_kwarg_exec") - workflow = WorkflowBuilder(start_executor=executor).build() - - result = await workflow.run("hello", messages=["stale"]) - assert result is not None - assert agent.call_count == 1 + fi_kwargs, ci_kwargs = executor._prepare_agent_run_args({}) # pyright: ignore[reportPrivateUsage] + assert fi_kwargs is None + assert ci_kwargs is None class _NonCopyableRaw: @@ -624,7 +562,7 @@ async def test_checkpoint_restore_works_without_context_mode_in_state() -> None: # Simulate a checkpoint state without context_mode (as saved by the new code) state: dict[str, Any] = { - "cache": [Message(role="user", text="cached msg")], + "cache": [Message(role="user", contents=["cached msg"])], "full_conversation": [], "agent_session": AgentSession().to_dict(), "pending_agent_requests": {}, @@ -638,3 +576,126 @@ async def test_checkpoint_restore_works_without_context_mode_in_state() -> None: assert cache[0].text == "cached msg" # context_mode should remain as configured in the constructor, not changed by restore assert executor._context_mode == "last_agent" # pyright: ignore[reportPrivateUsage] + + +# --------------------------------------------------------------------------- +# Per-executor kwargs resolution tests +# --------------------------------------------------------------------------- + + +async def test_resolve_executor_kwargs_returns_global_kwargs() -> None: + """_resolve_executor_kwargs with the global kwargs key returns the global kwargs.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + resolved = {GLOBAL_KWARGS_KEY: {"tool_param": "value"}} + result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] + assert result == {"tool_param": "value"} + + +async def test_resolve_executor_kwargs_returns_per_executor_kwargs() -> None: + """_resolve_executor_kwargs with matching executor ID returns that executor's kwargs.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + resolved = {"exec_a": {"my_param": 42}, "exec_b": {"other_param": 99}} + result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] + assert result == {"my_param": 42} + + +async def test_resolve_executor_kwargs_returns_none_for_unmatched_per_executor() -> None: + """_resolve_executor_kwargs returns None when per-executor dict has no matching ID.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_c") + + resolved = {"exec_a": {"my_param": 42}, "exec_b": {"other_param": 99}} + result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] + assert result is None + + +async def test_resolve_executor_kwargs_returns_none_for_none_input() -> None: + """_resolve_executor_kwargs returns None when input is None.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + result = executor._resolve_executor_kwargs(None) # pyright: ignore[reportPrivateUsage] + assert result is None + + +async def test_resolve_executor_kwargs_prefers_executor_id_over_global() -> None: + """_resolve_executor_kwargs prefers executor-specific entry over __global__.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + # Dict has both a per-executor entry and a global entry + resolved = {"exec_a": {"specific": True}, GLOBAL_KWARGS_KEY: {"global": True}} + result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] + assert result == {"specific": True} + + +async def test_prepare_agent_run_args_extracts_function_invocation_kwargs() -> None: + """_prepare_agent_run_args extracts function_invocation_kwargs from the state dict.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + raw: dict[str, Any] = { + "function_invocation_kwargs": {GLOBAL_KWARGS_KEY: {"tool_key": "tool_val"}}, + } + fi_kwargs, client_kwargs = executor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] + assert fi_kwargs == {"tool_key": "tool_val"} + assert client_kwargs is None + + +async def test_prepare_agent_run_args_extracts_client_kwargs() -> None: + """_prepare_agent_run_args extracts client_kwargs from the state dict.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + raw: dict[str, Any] = { + "client_kwargs": {GLOBAL_KWARGS_KEY: {"model": "gpt-4"}}, + } + fi_kwargs, client_kwargs = executor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] + assert fi_kwargs is None + assert client_kwargs == {"model": "gpt-4"} + + +async def test_prepare_agent_run_args_per_executor_resolution() -> None: + """_prepare_agent_run_args resolves per-executor function_invocation_kwargs using self.id.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + raw: dict[str, Any] = { + "function_invocation_kwargs": { + "exec_a": {"my_tool_key": "my_val"}, + "exec_b": {"other_tool_key": "other_val"}, + }, + } + fi_kwargs, _ = executor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] + assert fi_kwargs == {"my_tool_key": "my_val"} + + +async def test_prepare_agent_run_args_per_executor_no_match() -> None: + """_prepare_agent_run_args returns None for function_invocation_kwargs when executor ID not found.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_c") + + raw: dict[str, Any] = { + "function_invocation_kwargs": { + "exec_a": {"my_tool_key": "my_val"}, + "exec_b": {"other_tool_key": "other_val"}, + }, + } + fi_kwargs, _ = executor._prepare_agent_run_args(raw) # pyright: ignore[reportPrivateUsage] + assert fi_kwargs is None + + +async def test_resolve_executor_kwargs_empty_per_executor_does_not_fallback_to_global() -> None: + """An explicit empty per-executor dict should not fall through to global kwargs.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, id="exec_a") + + # Per-executor entry for exec_a is empty, but global has values. + # The empty dict should be honoured (no fallback to global). + resolved = {"exec_a": {}, GLOBAL_KWARGS_KEY: {"global_key": "global_val"}} + result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] + assert result == {} diff --git a/python/packages/core/tests/workflow/test_agent_run_event_typing.py b/python/packages/core/tests/workflow/test_agent_run_event_typing.py index ff8ef99893..2b16f01258 100644 --- a/python/packages/core/tests/workflow/test_agent_run_event_typing.py +++ b/python/packages/core/tests/workflow/test_agent_run_event_typing.py @@ -8,7 +8,7 @@ from agent_framework._workflows._events import WorkflowEvent def test_workflow_event_with_agent_response_data_type() -> None: """Verify WorkflowEvent[AgentResponse].data is typed as AgentResponse.""" - response = AgentResponse(messages=[Message(role="assistant", text="Hello")]) + response = AgentResponse(messages=[Message(role="assistant", contents=["Hello"])]) event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response) # This assignment should pass type checking without a cast @@ -29,7 +29,7 @@ def test_workflow_event_with_agent_response_update_data_type() -> None: def test_workflow_event_repr() -> None: """Verify WorkflowEvent.__repr__ uses consistent format.""" - response = AgentResponse(messages=[Message(role="assistant", text="Hello")]) + response = AgentResponse(messages=[Message(role="assistant", contents=["Hello"])]) event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response) repr_str = repr(event) diff --git a/python/packages/core/tests/workflow/test_executor.py b/python/packages/core/tests/workflow/test_executor.py index 77777e198b..e6be2e5bd0 100644 --- a/python/packages/core/tests/workflow/test_executor.py +++ b/python/packages/core/tests/workflow/test_executor.py @@ -540,7 +540,7 @@ async def test_executor_invoked_event_data_not_mutated_by_handler(): async def mutator(messages: list[Message], ctx: WorkflowContext[list[Message]]) -> None: # The handler mutates the input list by appending new messages original_len = len(messages) - messages.append(Message(role="assistant", text="Added by executor")) + messages.append(Message(role="assistant", contents=["Added by executor"])) await ctx.send_message(messages) # Verify mutation happened assert len(messages) == original_len + 1 @@ -548,7 +548,7 @@ async def test_executor_invoked_event_data_not_mutated_by_handler(): workflow = WorkflowBuilder(start_executor=mutator).build() # Run with a single user message - input_messages = [Message(role="user", text="hello")] + input_messages = [Message(role="user", contents=["hello"])] events = await workflow.run(input_messages) # Find the invoked event for the Mutator executor diff --git a/python/packages/core/tests/workflow/test_full_conversation.py b/python/packages/core/tests/workflow/test_full_conversation.py index b6b5260d83..b38b9400a2 100644 --- a/python/packages/core/tests/workflow/test_full_conversation.py +++ b/python/packages/core/tests/workflow/test_full_conversation.py @@ -322,7 +322,7 @@ class _RoundTripCoordinator(Executor): assert response.full_conversation is not None await ctx.send_message( AgentExecutorRequest( - messages=list(response.full_conversation) + [Message(role="user", text="apply feedback")], + messages=list(response.full_conversation) + [Message(role="user", contents=["apply feedback"])], should_respond=True, ), target_id=self._target_agent_id, @@ -418,7 +418,7 @@ class _FullHistoryReplayCoordinator(Executor): ctx: WorkflowContext[AgentExecutorRequest, Any], ) -> None: full_conv = list(response.full_conversation or response.agent_response.messages) - full_conv.append(Message(role="user", text="follow-up")) + full_conv.append(Message(role="user", contents=["follow-up"])) # Simulate a prior run: the target executor has a stored previous_response_id. self._target_exec._session.service_session_id = "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage] await ctx.send_message( diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index eacf70c6db..dd2100c1ae 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -344,7 +344,7 @@ class TestWorkflowAgent: workflow = WorkflowBuilder(start_executor=yielding_executor).build() # Run directly - should return output event (type='output') in result - direct_result = await workflow.run([Message(role="user", text="hello")]) + direct_result = await workflow.run([Message(role="user", contents=["hello"])]) direct_outputs = direct_result.get_outputs() assert len(direct_outputs) == 1 assert direct_outputs[0] == "processed: hello" @@ -479,8 +479,8 @@ class TestWorkflowAgent: async def list_yielding_executor(messages: list[Message], ctx: WorkflowContext[Never, list[Message]]) -> None: # Yield a list of Messages (as SequentialBuilder does) msg_list = [ - Message(role="user", text="first message"), - Message(role="assistant", text="second message"), + Message(role="user", contents=["first message"]), + Message(role="assistant", contents=["second message"]), Message( role="assistant", contents=[Content.from_text(text="third"), Content.from_text(text="fourth")], diff --git a/python/packages/core/tests/workflow/test_workflow_builder.py b/python/packages/core/tests/workflow/test_workflow_builder.py index 29a4bf0292..c780bf4ac9 100644 --- a/python/packages/core/tests/workflow/test_workflow_builder.py +++ b/python/packages/core/tests/workflow/test_workflow_builder.py @@ -65,7 +65,7 @@ class DummyAgent(BaseAgent): if isinstance(m, Message): norm.append(m) elif isinstance(m, str): - norm.append(Message(role="user", text=m)) + norm.append(Message(role="user", contents=[m])) return AgentResponse(messages=norm) async def _run_stream_impl(self) -> AsyncIterator[AgentResponseUpdate]: diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index d315f75f85..bba808f87a 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import AsyncIterable, Awaitable -from typing import Annotated, Any, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, overload import pytest @@ -15,7 +15,6 @@ from agent_framework import ( Message, ResponseStream, WorkflowRunState, - tool, ) from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY from agent_framework.orchestrations import ( @@ -26,22 +25,11 @@ from agent_framework.orchestrations import ( SequentialBuilder, ) +if TYPE_CHECKING: + from _pytest.logging import LogCaptureFixture + + # Track kwargs received by tools during test execution -_received_kwargs: list[dict[str, Any]] = [] - - -@tool(approval_mode="never_require") -def tool_with_kwargs( - action: Annotated[str, "The action to perform"], - **kwargs: Any, -) -> str: - """A test tool that captures kwargs for verification.""" - _received_kwargs.append(dict(kwargs)) - custom_data = kwargs.get("custom_data", {}) - user_token = kwargs.get("user_token", {}) - return f"Executed {action} with custom_data={custom_data}, user={user_token.get('user_name', 'unknown')}" - - class _KwargsCapturingAgent(BaseAgent): """Test agent that captures kwargs passed to run.""" @@ -92,76 +80,20 @@ class _KwargsCapturingAgent(BaseAgent): return _run() -class _OptionsAwareAgent(BaseAgent): - """Test agent that captures explicit `options` and kwargs passed to run().""" - - captured_options: list[dict[str, Any] | None] - captured_kwargs: list[dict[str, Any]] - - def __init__(self, name: str = "options_agent") -> None: - super().__init__(name=name, description="Test agent for options capture") - self.captured_options = [] - self.captured_kwargs = [] - - @overload - def run( - self, - messages: AgentRunInputs | None = ..., - *, - stream: Literal[False] = ..., - session: AgentSession | None = ..., - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]]: ... - @overload - def run( - self, - messages: AgentRunInputs | None = ..., - *, - stream: Literal[True], - session: AgentSession | None = ..., - **kwargs: Any, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... - - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: bool = False, - session: AgentSession | None = None, - options: dict[str, Any] | None = None, - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: - self.captured_options.append(dict(options) if options is not None else None) - self.captured_kwargs.append(dict(kwargs)) - if stream: - - async def _stream() -> AsyncIterable[AgentResponseUpdate]: - yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} response")]) - - return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) - - async def _run() -> AgentResponse: - return AgentResponse(messages=[Message("assistant", [f"{self.name} response"])]) - - return _run() - - # region Sequential Builder Tests async def test_sequential_kwargs_flow_to_agent() -> None: - """Test that kwargs passed to SequentialBuilder workflow flow through to agent.""" + """Test that function_invocation_kwargs passed to SequentialBuilder workflow flow through to agent.""" agent = _KwargsCapturingAgent(name="seq_agent") workflow = SequentialBuilder(participants=[agent]).build() - custom_data = {"endpoint": "https://api.example.com", "version": "v1"} - user_token = {"user_name": "alice", "access_level": "admin"} + fi_kwargs = {"endpoint": "https://api.example.com", "version": "v1"} async for event in workflow.run( "test message", stream=True, - custom_data=custom_data, - user_token=user_token, + function_invocation_kwargs=fi_kwargs, ): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -169,140 +101,53 @@ async def test_sequential_kwargs_flow_to_agent() -> None: # Verify agent received kwargs assert len(agent.captured_kwargs) >= 1, "Agent should have been invoked at least once" received = agent.captured_kwargs[0] - assert "custom_data" in received, "Agent should receive custom_data kwarg" - assert "user_token" in received, "Agent should receive user_token kwarg" - assert received["custom_data"] == custom_data - assert received["user_token"] == user_token + assert received.get("function_invocation_kwargs") == fi_kwargs async def test_sequential_kwargs_flow_to_multiple_agents() -> None: - """Test that kwargs flow to all agents in a sequential workflow.""" + """Test that function_invocation_kwargs flow to all agents in a sequential workflow.""" agent1 = _KwargsCapturingAgent(name="agent1") agent2 = _KwargsCapturingAgent(name="agent2") workflow = SequentialBuilder(participants=[agent1, agent2]).build() - custom_data = {"key": "value"} + fi_kwargs = {"key": "value"} - async for event in workflow.run("test", custom_data=custom_data, stream=True): + async for event in workflow.run("test", function_invocation_kwargs=fi_kwargs, stream=True): if event.type == "status" and event.state == WorkflowRunState.IDLE: break # Both agents should have received kwargs assert len(agent1.captured_kwargs) >= 1, "First agent should be invoked" assert len(agent2.captured_kwargs) >= 1, "Second agent should be invoked" - assert agent1.captured_kwargs[0].get("custom_data") == custom_data - assert agent2.captured_kwargs[0].get("custom_data") == custom_data + assert agent1.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs + assert agent2.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs async def test_sequential_run_kwargs_flow() -> None: - """Test that kwargs flow through workflow.run() (non-streaming).""" + """Test that function_invocation_kwargs flow through workflow.run() (non-streaming).""" agent = _KwargsCapturingAgent(name="run_agent") workflow = SequentialBuilder(participants=[agent]).build() - _ = await workflow.run("test message", custom_data={"test": True}) + _ = await workflow.run("test message", function_invocation_kwargs={"test": True}) assert len(agent.captured_kwargs) >= 1 - assert agent.captured_kwargs[0].get("custom_data") == {"test": True} + assert agent.captured_kwargs[0].get("function_invocation_kwargs") == {"test": True} -async def test_sequential_run_options_does_not_conflict_with_agent_options() -> None: - """Test workflow.run(options=...) does not conflict with Agent.run(options=...).""" - agent = _OptionsAwareAgent(name="options_agent") +async def test_sequential_run_non_streaming_kwargs_flow() -> None: + """Test workflow.run(function_invocation_kwargs=...) non-streaming path.""" + agent = _KwargsCapturingAgent(name="options_agent") workflow = SequentialBuilder(participants=[agent]).build() - custom_data = {"session_id": "abc123"} - user_token = {"user_name": "alice"} - provided_options = { - "store": False, - "additional_function_arguments": {"source": "workflow-options"}, - } + fi_kwargs = {"session_id": "abc123"} - async for event in workflow.run( + _ = await workflow.run( "test message", - stream=True, - options=provided_options, - custom_data=custom_data, - user_token=user_token, - ): - if event.type == "status" and event.state == WorkflowRunState.IDLE: - break - - assert len(agent.captured_options) >= 1 - captured_options: dict[str, Any] | None = agent.captured_options[0] - assert captured_options is not None - assert captured_options.get("store") is False - - additional_args: Any = captured_options.get("additional_function_arguments") - assert isinstance(additional_args, dict) - assert additional_args.get("source") == "workflow-options" # pyright: ignore[reportUnknownMemberType] - assert additional_args.get("custom_data") == custom_data # pyright: ignore[reportUnknownMemberType] - assert additional_args.get("user_token") == user_token # pyright: ignore[reportUnknownMemberType] - - # "options" should be passed once via the dedicated options parameter, - # not duplicated in **kwargs. - assert len(agent.captured_kwargs) >= 1 - captured_kwargs = agent.captured_kwargs[0] - assert "options" not in captured_kwargs - assert captured_kwargs.get("custom_data") == custom_data - assert captured_kwargs.get("user_token") == user_token - - -async def test_sequential_run_additional_function_arguments_flattened() -> None: - """Test workflow.run(additional_function_arguments=...) maps directly to tool kwargs.""" - agent = _OptionsAwareAgent(name="options_agent") - workflow = SequentialBuilder(participants=[agent]).build() - - custom_data = {"session_id": "abc123"} - user_token = {"user_name": "alice"} - - async for event in workflow.run( - "test message", - stream=True, - additional_function_arguments={"custom_data": custom_data, "user_token": user_token}, - ): - if event.type == "status" and event.state == WorkflowRunState.IDLE: - break - - assert len(agent.captured_options) >= 1 - captured_options: dict[str, Any] | None = agent.captured_options[0] - assert captured_options is not None - - additional_args: Any = captured_options.get("additional_function_arguments") - assert isinstance(additional_args, dict) - assert additional_args.get("custom_data") == custom_data # pyright: ignore[reportUnknownMemberType] - assert additional_args.get("user_token") == user_token # pyright: ignore[reportUnknownMemberType] - assert "additional_function_arguments" not in additional_args + function_invocation_kwargs=fi_kwargs, + ) assert len(agent.captured_kwargs) >= 1 - captured_kwargs = agent.captured_kwargs[0] - assert "additional_function_arguments" not in captured_kwargs - - -async def test_sequential_run_additional_function_arguments_merges_with_options() -> None: - """Test workflow additional_function_arguments merges with workflow options.""" - agent = _OptionsAwareAgent(name="options_agent") - workflow = SequentialBuilder(participants=[agent]).build() - - async for event in workflow.run( - "test message", - stream=True, - options={"additional_function_arguments": {"source": "workflow-options"}}, - additional_function_arguments={"custom_data": {"session_id": "abc123"}}, - user_token={"user_name": "alice"}, - ): - if event.type == "status" and event.state == WorkflowRunState.IDLE: - break - - assert len(agent.captured_options) >= 1 - captured_options: dict[str, Any] | None = agent.captured_options[0] - assert captured_options is not None - - additional_args: Any = captured_options.get("additional_function_arguments") - assert isinstance(additional_args, dict) - assert additional_args.get("source") == "workflow-options" # pyright: ignore[reportUnknownMemberType] - assert additional_args.get("custom_data") == {"session_id": "abc123"} # pyright: ignore[reportUnknownMemberType] - assert additional_args.get("user_token") == {"user_name": "alice"} # pyright: ignore[reportUnknownMemberType] - assert "additional_function_arguments" not in additional_args + assert agent.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs # endregion @@ -312,19 +157,17 @@ async def test_sequential_run_additional_function_arguments_merges_with_options( async def test_concurrent_kwargs_flow_to_agents() -> None: - """Test that kwargs flow to all agents in a concurrent workflow.""" + """Test that function_invocation_kwargs flow to all agents in a concurrent workflow.""" agent1 = _KwargsCapturingAgent(name="concurrent1") agent2 = _KwargsCapturingAgent(name="concurrent2") workflow = ConcurrentBuilder(participants=[agent1, agent2]).build() - custom_data = {"batch_id": "123"} - user_token = {"user_name": "bob"} + fi_kwargs = {"batch_id": "123"} async for event in workflow.run( "concurrent test", stream=True, - custom_data=custom_data, - user_token=user_token, + function_invocation_kwargs=fi_kwargs, ): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -335,8 +178,7 @@ async def test_concurrent_kwargs_flow_to_agents() -> None: for agent in [agent1, agent2]: received = agent.captured_kwargs[0] - assert received.get("custom_data") == custom_data - assert received.get("user_token") == user_token + assert received.get("function_invocation_kwargs") == fi_kwargs # endregion @@ -346,7 +188,7 @@ async def test_concurrent_kwargs_flow_to_agents() -> None: async def test_groupchat_kwargs_flow_to_agents() -> None: - """Test that kwargs flow to agents in a group chat workflow.""" + """Test that function_invocation_kwargs flow to agents in a group chat workflow.""" agent1 = _KwargsCapturingAgent(name="chat1") agent2 = _KwargsCapturingAgent(name="chat2") @@ -368,9 +210,9 @@ async def test_groupchat_kwargs_flow_to_agents() -> None: selection_func=simple_selector, ).build() - custom_data = {"session_id": "group123"} + fi_kwargs = {"session_id": "group123"} - async for event in workflow.run("group chat test", custom_data=custom_data, stream=True): + async for event in workflow.run("group chat test", function_invocation_kwargs=fi_kwargs, stream=True): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -379,7 +221,7 @@ async def test_groupchat_kwargs_flow_to_agents() -> None: assert len(all_kwargs) >= 1, "At least one agent should be invoked in group chat" for received in all_kwargs: - assert received.get("custom_data") == custom_data + assert received.get("function_invocation_kwargs") == fi_kwargs # endregion @@ -389,7 +231,7 @@ async def test_groupchat_kwargs_flow_to_agents() -> None: async def test_kwargs_stored_in_state() -> None: - """Test that kwargs are stored in State with the correct key.""" + """Test that function_invocation_kwargs are stored in State with the correct key.""" from agent_framework import Executor, WorkflowContext, handler stored_kwargs: dict[str, Any] | None = None @@ -404,13 +246,12 @@ async def test_kwargs_stored_in_state() -> None: inspector = _StateInspector(id="inspector") workflow = SequentialBuilder(participants=[inspector]).build() - async for event in workflow.run("test", my_kwarg="my_value", another=123, stream=True): + async for event in workflow.run("test", function_invocation_kwargs={"my_kwarg": "my_value"}, stream=True): if event.type == "status" and event.state == WorkflowRunState.IDLE: break assert stored_kwargs is not None, "kwargs should be stored in State" - assert stored_kwargs.get("my_kwarg") == "my_value" - assert stored_kwargs.get("another") == 123 + assert "function_invocation_kwargs" in stored_kwargs async def test_empty_kwargs_stored_as_empty_dict() -> None: @@ -444,24 +285,8 @@ async def test_empty_kwargs_stored_as_empty_dict() -> None: # region Edge Cases -async def test_kwargs_with_none_values() -> None: - """Test that kwargs with None values are passed through correctly.""" - agent = _KwargsCapturingAgent(name="none_test") - workflow = SequentialBuilder(participants=[agent]).build() - - async for event in workflow.run("test", optional_param=None, other_param="value", stream=True): - if event.type == "status" and event.state == WorkflowRunState.IDLE: - break - - assert len(agent.captured_kwargs) >= 1 - received = agent.captured_kwargs[0] - assert "optional_param" in received - assert received["optional_param"] is None - assert received["other_param"] == "value" - - async def test_kwargs_with_complex_nested_data() -> None: - """Test that complex nested data structures flow through correctly.""" + """Test that complex nested data structures flow through correctly via function_invocation_kwargs.""" agent = _KwargsCapturingAgent(name="nested_test") workflow = SequentialBuilder(participants=[agent]).build() @@ -476,17 +301,17 @@ async def test_kwargs_with_complex_nested_data() -> None: "tuple_like": [1, 2, 3], } - async for event in workflow.run("test", complex_data=complex_data, stream=True): + async for event in workflow.run("test", function_invocation_kwargs=complex_data, stream=True): if event.type == "status" and event.state == WorkflowRunState.IDLE: break assert len(agent.captured_kwargs) >= 1 received = agent.captured_kwargs[0] - assert received.get("complex_data") == complex_data + assert received.get("function_invocation_kwargs") == complex_data async def test_kwargs_preserved_on_response_continuation() -> None: - """Test that run kwargs are preserved when continuing a paused workflow with run(responses=...). + """Test that function_invocation_kwargs are preserved when continuing a paused workflow with run(responses=...). Regression test for #4293: kwargs were overwritten to {} on continuation calls. """ @@ -550,8 +375,9 @@ async def test_kwargs_preserved_on_response_continuation() -> None: agent = _ApprovalCapturingAgent() workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build() - # Initial run with kwargs — workflow should pause for approval - result = await workflow.run("go", custom_data={"token": "abc"}) + # Initial run with function_invocation_kwargs — workflow should pause for approval + fi_kwargs = {"token": "abc"} + result = await workflow.run("go", function_invocation_kwargs=fi_kwargs) request_events = result.get_request_info_events() assert len(request_events) == 1 @@ -559,174 +385,14 @@ async def test_kwargs_preserved_on_response_continuation() -> None: approval = request_events[0] await workflow.run(responses={approval.request_id: approval.data.to_function_approval_response(True)}) - # Both calls should have received the original kwargs + # Both calls should have received the original function_invocation_kwargs assert len(agent.captured_kwargs) == 2 - assert agent.captured_kwargs[0].get("custom_data") == {"token": "abc"} - assert agent.captured_kwargs[1].get("custom_data") == {"token": "abc"}, ( + assert agent.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs + assert agent.captured_kwargs[1].get("function_invocation_kwargs") == fi_kwargs, ( f"kwargs should be preserved on continuation, got: {agent.captured_kwargs[1]}" ) -async def test_kwargs_overridden_on_response_continuation() -> None: - """Test that explicitly provided kwargs override prior kwargs on continuation.""" - - class _ApprovalCapturingAgent(BaseAgent): - captured_kwargs: list[dict[str, Any]] - _asked: bool - - def __init__(self) -> None: - super().__init__(name="approval_agent", description="Test agent") - self.captured_kwargs = [] - self._asked = False - - @overload - def run( - self, - messages: AgentRunInputs | None = ..., - *, - stream: Literal[False] = ..., - session: AgentSession | None = ..., - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]]: ... - @overload - def run( - self, - messages: AgentRunInputs | None = ..., - *, - stream: Literal[True], - session: AgentSession | None = ..., - **kwargs: Any, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... - - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: bool = False, - session: AgentSession | None = None, - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: - self.captured_kwargs.append(dict(kwargs)) - if not self._asked: - self._asked = True - - async def _pause() -> AgentResponse: - call = Content.from_function_call(call_id="c1", name="do_thing", arguments="{}") - req = Content.from_function_approval_request(id="r1", function_call=call) - return AgentResponse(messages=[Message("assistant", [req])]) - - return _pause() - - async def _done() -> AgentResponse: - return AgentResponse(messages=[Message("assistant", ["done"])]) - - return _done() - - from agent_framework import WorkflowBuilder - - agent = _ApprovalCapturingAgent() - workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build() - - result = await workflow.run("go", custom_data={"token": "abc"}) - request_events = result.get_request_info_events() - approval = request_events[0] - - # Continue with responses AND new kwargs — should override - await workflow.run( - responses={approval.request_id: approval.data.to_function_approval_response(True)}, - custom_data={"token": "xyz"}, - ) - - assert len(agent.captured_kwargs) == 2 - assert agent.captured_kwargs[0].get("custom_data") == {"token": "abc"} - assert agent.captured_kwargs[1].get("custom_data") == {"token": "xyz"} - - -async def test_kwargs_empty_value_passed_on_continuation() -> None: - """Test that explicitly passing a kwarg with an empty value on continuation overrides prior kwargs. - - This exercises the boundary where the caller provides kwargs (e.g., custom_data={}) - that differ from the original run. Because the kwargs dict is non-empty (it has a key), - it passes the `kwargs if kwargs else None` gate and the `is not None` check, so it - overwrites the previously stored kwargs. - """ - - class _ApprovalCapturingAgent(BaseAgent): - captured_kwargs: list[dict[str, Any]] - _asked: bool - - def __init__(self) -> None: - super().__init__(name="approval_agent", description="Test agent") - self.captured_kwargs = [] - self._asked = False - - @overload - def run( - self, - messages: AgentRunInputs | None = ..., - *, - stream: Literal[False] = ..., - session: AgentSession | None = ..., - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]]: ... - @overload - def run( - self, - messages: AgentRunInputs | None = ..., - *, - stream: Literal[True], - session: AgentSession | None = ..., - **kwargs: Any, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... - - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: bool = False, - session: AgentSession | None = None, - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: - self.captured_kwargs.append(dict(kwargs)) - if not self._asked: - self._asked = True - - async def _pause() -> AgentResponse: - call = Content.from_function_call(call_id="c1", name="do_thing", arguments="{}") - req = Content.from_function_approval_request(id="r1", function_call=call) - return AgentResponse(messages=[Message("assistant", [req])]) - - return _pause() - - async def _done() -> AgentResponse: - return AgentResponse(messages=[Message("assistant", ["done"])]) - - return _done() - - from agent_framework import WorkflowBuilder - - agent = _ApprovalCapturingAgent() - workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build() - - # Initial run with non-empty kwargs - result = await workflow.run("go", custom_data={"token": "abc"}) - request_events = result.get_request_info_events() - assert len(request_events) == 1 - - # Continue with custom_data={} — explicitly clearing the value. - # kwargs={"custom_data": {}} is truthy (has a key), so run_kwargs is set. - approval = request_events[0] - await workflow.run( - responses={approval.request_id: approval.data.to_function_approval_response(True)}, - custom_data={}, - ) - - assert len(agent.captured_kwargs) == 2 - assert agent.captured_kwargs[0].get("custom_data") == {"token": "abc"} - # The continuation explicitly set custom_data={}, overriding the original - assert agent.captured_kwargs[1].get("custom_data") == {} - - async def test_kwargs_reset_context_stores_empty_dict() -> None: """Test that reset_context=True with no kwargs stores an empty dict. @@ -743,33 +409,9 @@ async def test_kwargs_reset_context_stores_empty_dict() -> None: break assert len(agent.captured_kwargs) >= 1 - # The only kwarg should be the framework-injected 'options' (no user-provided kwargs) received = agent.captured_kwargs[0] - assert "custom_data" not in received - assert received.get("options") is None - - -async def test_kwargs_preserved_across_workflow_reruns() -> None: - """Test that kwargs are correctly isolated between workflow runs.""" - agent = _KwargsCapturingAgent(name="rerun_test") - - # Build separate workflows for each run to avoid "already running" error - workflow1 = SequentialBuilder(participants=[agent]).build() - workflow2 = SequentialBuilder(participants=[agent]).build() - - # First run - async for event in workflow1.run("run1", run_id="first", stream=True): - if event.type == "status" and event.state == WorkflowRunState.IDLE: - break - - # Second run with different kwargs (using fresh workflow) - async for event in workflow2.run("run2", run_id="second", stream=True): - if event.type == "status" and event.state == WorkflowRunState.IDLE: - break - - assert len(agent.captured_kwargs) >= 2 - assert agent.captured_kwargs[0].get("run_id") == "first" - assert agent.captured_kwargs[1].get("run_id") == "second" + assert received.get("function_invocation_kwargs") is None + assert received.get("client_kwargs") is None # endregion @@ -780,7 +422,7 @@ async def test_kwargs_preserved_across_workflow_reruns() -> None: @pytest.mark.xfail(reason="Handoff workflow does not yet propagate kwargs to agents") async def test_handoff_kwargs_flow_to_agents() -> None: - """Test that kwargs flow to agents in a handoff workflow.""" + """Test that function_invocation_kwargs flow to agents in a handoff workflow.""" agent1 = _KwargsCapturingAgent(name="coordinator") agent2 = _KwargsCapturingAgent(name="specialist") @@ -792,15 +434,15 @@ async def test_handoff_kwargs_flow_to_agents() -> None: .build() ) - custom_data = {"session_id": "handoff123"} + fi_kwargs = {"session_id": "handoff123"} - async for event in workflow.run("handoff test", custom_data=custom_data, stream=True): + async for event in workflow.run("handoff test", function_invocation_kwargs=fi_kwargs, stream=True): if event.type == "status" and event.state == WorkflowRunState.IDLE: break # Coordinator agent should have received kwargs assert len(agent1.captured_kwargs) >= 1, "Coordinator should be invoked in handoff" - assert agent1.captured_kwargs[0].get("custom_data") == custom_data + assert agent1.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs # endregion @@ -827,10 +469,10 @@ async def test_magentic_kwargs_flow_to_agents() -> None: self.task_ledger = None async def plan(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="Plan: Test task", author_name="manager") + return Message(role="assistant", contents=["Plan: Test task"], author_name="manager") async def replan(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="Replan: Test task", author_name="manager") + return Message(role="assistant", contents=["Replan: Test task"], author_name="manager") async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: # Return completed on first call @@ -843,7 +485,7 @@ async def test_magentic_kwargs_flow_to_agents() -> None: ) async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="Final answer", author_name="manager") + return Message(role="assistant", contents=["Final answer"], author_name="manager") agent = _KwargsCapturingAgent(name="agent1") manager = _MockManager() @@ -852,7 +494,7 @@ async def test_magentic_kwargs_flow_to_agents() -> None: custom_data = {"session_id": "magentic123"} - async for event in workflow.run("magentic test", custom_data=custom_data, stream=True): + async for event in workflow.run("magentic test", function_invocation_kwargs=custom_data, stream=True): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -878,10 +520,10 @@ async def test_magentic_kwargs_stored_in_state() -> None: self.task_ledger = None async def plan(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="Plan", author_name="manager") + return Message(role="assistant", contents=["Plan"], author_name="manager") async def replan(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="Replan", author_name="manager") + return Message(role="assistant", contents=["Replan"], author_name="manager") async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: return MagenticProgressLedger( @@ -893,7 +535,7 @@ async def test_magentic_kwargs_stored_in_state() -> None: ) async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="Final", author_name="manager") + return Message(role="assistant", contents=["Final"], author_name="manager") agent = _KwargsCapturingAgent(name="agent1") manager = _MockManager() @@ -903,7 +545,7 @@ async def test_magentic_kwargs_stored_in_state() -> None: # Use MagenticWorkflow.run() which goes through the kwargs attachment path custom_data = {"magentic_key": "magentic_value"} - async for event in magentic_workflow.run("test task", custom_data=custom_data, stream=True): + async for event in magentic_workflow.run("test task", function_invocation_kwargs=custom_data, stream=True): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -918,86 +560,61 @@ async def test_magentic_kwargs_stored_in_state() -> None: async def test_workflow_as_agent_run_propagates_kwargs_to_underlying_agent() -> None: - """Test that kwargs passed to workflow_agent.run() flow through to the underlying agents.""" + """Test that function_invocation_kwargs passed to workflow_agent.run() flow through to the underlying agents.""" agent = _KwargsCapturingAgent(name="inner_agent") workflow = SequentialBuilder(participants=[agent]).build() workflow_agent = workflow.as_agent(name="TestWorkflowAgent") - custom_data = {"endpoint": "https://api.example.com", "version": "v1"} - user_token = {"user_name": "alice", "access_level": "admin"} + fi_kwargs = {"endpoint": "https://api.example.com", "version": "v1"} _ = await workflow_agent.run( "test message", - custom_data=custom_data, - user_token=user_token, + function_invocation_kwargs=fi_kwargs, ) # Verify inner agent received kwargs assert len(agent.captured_kwargs) >= 1, "Inner agent should have been invoked at least once" received = agent.captured_kwargs[0] - assert "custom_data" in received, "Inner agent should receive custom_data kwarg" - assert "user_token" in received, "Inner agent should receive user_token kwarg" - assert received["custom_data"] == custom_data - assert received["user_token"] == user_token + assert received.get("function_invocation_kwargs") == fi_kwargs async def test_workflow_as_agent_run_stream_propagates_kwargs_to_underlying_agent() -> None: - """Test that kwargs passed to workflow_agent.run() flow through to the underlying agents.""" + """Test that function_invocation_kwargs passed to workflow_agent.run(stream=True) flow through.""" agent = _KwargsCapturingAgent(name="inner_agent") workflow = SequentialBuilder(participants=[agent]).build() workflow_agent = workflow.as_agent(name="TestWorkflowAgent") - custom_data = {"session_id": "xyz123"} - api_token = "secret-token" + fi_kwargs = {"session_id": "xyz123"} async for _ in workflow_agent.run( "test message", stream=True, - custom_data=custom_data, - api_token=api_token, + function_invocation_kwargs=fi_kwargs, ): pass # Verify inner agent received kwargs assert len(agent.captured_kwargs) >= 1, "Inner agent should have been invoked at least once" received = agent.captured_kwargs[0] - assert "custom_data" in received, "Inner agent should receive custom_data kwarg" - assert "api_token" in received, "Inner agent should receive api_token kwarg" - assert received["custom_data"] == custom_data - assert received["api_token"] == api_token + assert received.get("function_invocation_kwargs") == fi_kwargs async def test_workflow_as_agent_propagates_kwargs_to_multiple_agents() -> None: - """Test that kwargs flow to all agents when using workflow.as_agent().""" + """Test that function_invocation_kwargs flow to all agents when using workflow.as_agent().""" agent1 = _KwargsCapturingAgent(name="agent1") agent2 = _KwargsCapturingAgent(name="agent2") workflow = SequentialBuilder(participants=[agent1, agent2]).build() workflow_agent = workflow.as_agent(name="MultiAgentWorkflow") - custom_data = {"batch_id": "batch-001"} + fi_kwargs = {"batch_id": "batch-001"} - _ = await workflow_agent.run("test message", custom_data=custom_data) + _ = await workflow_agent.run("test message", function_invocation_kwargs=fi_kwargs) # Both agents should have received kwargs assert len(agent1.captured_kwargs) >= 1, "First agent should be invoked" assert len(agent2.captured_kwargs) >= 1, "Second agent should be invoked" - assert agent1.captured_kwargs[0].get("custom_data") == custom_data - assert agent2.captured_kwargs[0].get("custom_data") == custom_data - - -async def test_workflow_as_agent_kwargs_with_none_values() -> None: - """Test that kwargs with None values are passed through correctly via as_agent().""" - agent = _KwargsCapturingAgent(name="none_test_agent") - workflow = SequentialBuilder(participants=[agent]).build() - workflow_agent = workflow.as_agent(name="NoneTestWorkflow") - - _ = await workflow_agent.run("test", optional_param=None, other_param="value") - - assert len(agent.captured_kwargs) >= 1 - received = agent.captured_kwargs[0] - assert "optional_param" in received - assert received["optional_param"] is None - assert received["other_param"] == "value" + assert agent1.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs + assert agent2.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs async def test_workflow_as_agent_kwargs_with_complex_nested_data() -> None: @@ -1016,11 +633,11 @@ async def test_workflow_as_agent_kwargs_with_complex_nested_data() -> None: }, } - _ = await workflow_agent.run("test", complex_data=complex_data) + _ = await workflow_agent.run("test", function_invocation_kwargs=complex_data) assert len(agent.captured_kwargs) >= 1 received = agent.captured_kwargs[0] - assert received.get("complex_data") == complex_data + assert received.get("function_invocation_kwargs") == complex_data # endregion @@ -1050,15 +667,13 @@ async def test_subworkflow_kwargs_propagation() -> None: outer_workflow = SequentialBuilder(participants=[subworkflow_executor]).build() # Define kwargs that should propagate to subworkflow - custom_data = {"api_key": "secret123", "endpoint": "https://api.example.com"} - user_token = {"user_name": "alice", "access_level": "admin"} + fi_kwargs = {"api_key": "secret123", "endpoint": "https://api.example.com"} # Run the outer workflow with kwargs async for event in outer_workflow.run( "test message for subworkflow", stream=True, - custom_data=custom_data, - user_token=user_token, + function_invocation_kwargs=fi_kwargs, ): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -1069,17 +684,8 @@ async def test_subworkflow_kwargs_propagation() -> None: received_kwargs = inner_agent.captured_kwargs[0] # Verify kwargs were propagated from parent workflow to subworkflow agent - assert "custom_data" in received_kwargs, ( - f"Subworkflow agent should receive 'custom_data' kwarg. Received keys: {list(received_kwargs.keys())}" - ) - assert "user_token" in received_kwargs, ( - f"Subworkflow agent should receive 'user_token' kwarg. Received keys: {list(received_kwargs.keys())}" - ) - assert received_kwargs.get("custom_data") == custom_data, ( - f"Expected custom_data={custom_data}, got {received_kwargs.get('custom_data')}" - ) - assert received_kwargs.get("user_token") == user_token, ( - f"Expected user_token={user_token}, got {received_kwargs.get('user_token')}" + assert received_kwargs.get("function_invocation_kwargs") == fi_kwargs, ( + f"Expected function_invocation_kwargs={fi_kwargs}, got {received_kwargs.get('function_invocation_kwargs')}" ) @@ -1114,11 +720,11 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None: outer_workflow = SequentialBuilder(participants=[subworkflow_executor]).build() # Run with kwargs + fi_kwargs = {"my_custom_kwarg": "should_be_propagated", "another_kwarg": 42} async for event in outer_workflow.run( "test", stream=True, - my_custom_kwarg="should_be_propagated", - another_kwarg=42, + function_invocation_kwargs=fi_kwargs, ): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -1128,11 +734,8 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None: kwargs_in_subworkflow = captured_kwargs_from_state[0] - assert kwargs_in_subworkflow.get("my_custom_kwarg") == "should_be_propagated", ( - f"Expected 'my_custom_kwarg' in subworkflow got: {kwargs_in_subworkflow}" - ) - assert kwargs_in_subworkflow.get("another_kwarg") == 42, ( - f"Expected 'another_kwarg'=42 in subworkflow got: {kwargs_in_subworkflow}" + assert "function_invocation_kwargs" in kwargs_in_subworkflow, ( + f"Expected 'function_invocation_kwargs' in subworkflow state, got: {kwargs_in_subworkflow}" ) @@ -1164,7 +767,7 @@ async def test_nested_subworkflow_kwargs_propagation() -> None: async for event in outer_workflow.run( "deeply nested test", stream=True, - deep_kwarg="should_reach_inner", + function_invocation_kwargs={"deep_kwarg": "should_reach_inner"}, ): if event.type == "status" and event.state == WorkflowRunState.IDLE: break @@ -1173,9 +776,250 @@ async def test_nested_subworkflow_kwargs_propagation() -> None: assert len(inner_agent.captured_kwargs) >= 1, "Deeply nested agent should be invoked" received = inner_agent.captured_kwargs[0] - assert received.get("deep_kwarg") == "should_reach_inner", ( + assert received.get("function_invocation_kwargs") == {"deep_kwarg": "should_reach_inner"}, ( f"Deeply nested agent should receive 'deep_kwarg'. Got: {received}" ) # endregion + + +# region Per-Executor Invocation Kwargs Tests + + +async def test_function_and_client_kwargs_together() -> None: + """Both function_invocation_kwargs and client_kwargs can be provided in the same call.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + fi_kwargs = {"tool_param": "tool_value"} + ci_kwargs = {"temperature": 0.7} + + async for event in workflow.run( + "test", + stream=True, + function_invocation_kwargs=fi_kwargs, + client_kwargs=ci_kwargs, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + # Both agents should receive both kwargs + for agent in [agent1, agent2]: + assert len(agent.captured_kwargs) >= 1 + assert agent.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs + assert agent.captured_kwargs[0].get("client_kwargs") == ci_kwargs + + +async def test_global_function_invocation_kwargs_flow_to_all_agents() -> None: + """Global function_invocation_kwargs should be received by all agents in a sequential workflow.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + fi_kwargs = {"tool_param": "shared_value"} + + async for event in workflow.run( + "test", + stream=True, + function_invocation_kwargs=fi_kwargs, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + # Both agents should receive function_invocation_kwargs + assert len(agent1.captured_kwargs) >= 1 + assert agent1.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs + assert len(agent2.captured_kwargs) >= 1 + assert agent2.captured_kwargs[0].get("function_invocation_kwargs") == fi_kwargs + + +async def test_per_executor_function_invocation_kwargs_routes_to_correct_agent() -> None: + """Per-executor function_invocation_kwargs should only be received by the targeted agent.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + # Per-executor: keys match agent names (which are used as executor IDs) + fi_kwargs = { + "agent1": {"tool_param": "value_for_agent1"}, + "agent2": {"tool_param": "value_for_agent2"}, + } + + async for event in workflow.run( + "test", + stream=True, + function_invocation_kwargs=fi_kwargs, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + # Each agent should receive only its own kwargs + assert len(agent1.captured_kwargs) >= 1 + assert agent1.captured_kwargs[0].get("function_invocation_kwargs") == {"tool_param": "value_for_agent1"} + assert len(agent2.captured_kwargs) >= 1 + assert agent2.captured_kwargs[0].get("function_invocation_kwargs") == {"tool_param": "value_for_agent2"} + + +async def test_per_executor_kwargs_unmatched_agent_gets_none() -> None: + """An agent not targeted in per-executor kwargs should receive None for that kwarg.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + # Only agent1 is targeted + fi_kwargs = {"agent1": {"tool_param": "only_for_agent1"}} + + async for event in workflow.run( + "test", + stream=True, + function_invocation_kwargs=fi_kwargs, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert len(agent1.captured_kwargs) >= 1 + assert agent1.captured_kwargs[0].get("function_invocation_kwargs") == {"tool_param": "only_for_agent1"} + assert len(agent2.captured_kwargs) >= 1 + assert agent2.captured_kwargs[0].get("function_invocation_kwargs") is None + + +async def test_global_client_kwargs_flow_to_all_agents() -> None: + """Global client_kwargs should be received by all agents.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + ci_kwargs = {"temperature": 0.5} + + async for event in workflow.run( + "test", + stream=True, + client_kwargs=ci_kwargs, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert len(agent1.captured_kwargs) >= 1 + assert agent1.captured_kwargs[0].get("client_kwargs") == ci_kwargs + assert len(agent2.captured_kwargs) >= 1 + assert agent2.captured_kwargs[0].get("client_kwargs") == ci_kwargs + + +async def test_per_executor_client_kwargs_routes_correctly() -> None: + """Per-executor client_kwargs should only be received by the targeted agent.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + ci_kwargs = { + "agent1": {"temperature": 0.1}, + "agent2": {"temperature": 0.9}, + } + + async for event in workflow.run( + "test", + stream=True, + client_kwargs=ci_kwargs, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert len(agent1.captured_kwargs) >= 1 + assert agent1.captured_kwargs[0].get("client_kwargs") == {"temperature": 0.1} + assert len(agent2.captured_kwargs) >= 1 + assert agent2.captured_kwargs[0].get("client_kwargs") == {"temperature": 0.9} + + +async def test_resolve_invocation_kwargs_logs_per_executor(caplog: "LogCaptureFixture") -> None: + """Workflow._resolve_invocation_kwargs logs info when per-executor format is detected.""" + import logging + + agent = _KwargsCapturingAgent(name="agent1") + workflow = SequentialBuilder(participants=[agent]).build() + + with caplog.at_level(logging.INFO): + async for event in workflow.run( + "test", + stream=True, + function_invocation_kwargs={"agent1": {"key": "val"}}, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + per_executor_logs = [r for r in caplog.records if "per-executor" in r.message.lower()] + assert len(per_executor_logs) >= 1 + + +async def test_resolve_invocation_kwargs_logs_global(caplog: "LogCaptureFixture") -> None: + """Workflow._resolve_invocation_kwargs logs info when global format is detected.""" + import logging + + agent = _KwargsCapturingAgent(name="agent1") + workflow = SequentialBuilder(participants=[agent]).build() + + with caplog.at_level(logging.INFO): + async for event in workflow.run( + "test", + stream=True, + function_invocation_kwargs={"tool_key": "tool_val"}, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + global_logs = [r for r in caplog.records if "global kwargs" in r.message.lower()] + assert len(global_logs) >= 1 + + +async def test_empty_function_invocation_kwargs_clears_previous() -> None: + """Passing function_invocation_kwargs={} should clear previously stored kwargs on a new run.""" + agent = _KwargsCapturingAgent(name="clearing_agent") + workflow = SequentialBuilder(participants=[agent]).build() + + # First run: provide kwargs + await workflow.run( + "first", + function_invocation_kwargs={"key": "value"}, + ) + + assert len(agent.captured_kwargs) >= 1 + assert agent.captured_kwargs[0].get("function_invocation_kwargs") == {"key": "value"} + + # Second run: pass empty dict to explicitly clear + await workflow.run( + "second", + function_invocation_kwargs={}, + ) + + # Agent should receive None because the empty dict resolves to an empty + # __global__ entry which is treated as "no kwargs" for each executor. + assert len(agent.captured_kwargs) >= 2 + assert agent.captured_kwargs[-1].get("function_invocation_kwargs") == {} + + +async def test_empty_client_kwargs_clears_previous() -> None: + """Passing client_kwargs={} should clear previously stored kwargs on a new run.""" + agent = _KwargsCapturingAgent(name="clearing_agent") + workflow = SequentialBuilder(participants=[agent]).build() + + # First run: provide kwargs + await workflow.run( + "first", + client_kwargs={"temperature": 0.5}, + ) + + assert len(agent.captured_kwargs) >= 1 + assert agent.captured_kwargs[0].get("client_kwargs") == {"temperature": 0.5} + + # Second run: pass empty dict to explicitly clear + await workflow.run( + "second", + client_kwargs={}, + ) + + assert len(agent.captured_kwargs) >= 2 + assert agent.captured_kwargs[-1].get("client_kwargs") == {} + + +# endregion diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 625189a2f4..a9c534ee2d 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -46,59 +46,74 @@ else: class ProviderTypeMapping(TypedDict, total=True): package: str name: str - model_id_field: str + model_field: str + endpoint_field: str | None + api_key_field: str | None PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = { - "AzureOpenAI.Chat": { - "package": "agent_framework.azure", - "name": "AzureOpenAIChatClient", - "model_id_field": "deployment_name", + "AzureOpenAI": { + "package": "agent_framework.openai", + "name": "OpenAIChatClient", + "model_field": "model", + "endpoint_field": "azure_endpoint", + "api_key_field": "api_key", }, - "AzureOpenAI.Assistants": { - "package": "agent_framework.azure", - "name": "AzureOpenAIAssistantsClient", - "model_id_field": "deployment_name", + "AzureOpenAI.Chat": { + "package": "agent_framework.openai", + "name": "OpenAIChatCompletionClient", + "model_field": "model", + "endpoint_field": "azure_endpoint", + "api_key_field": "api_key", }, "AzureOpenAI.Responses": { - "package": "agent_framework.azure", - "name": "AzureOpenAIResponsesClient", - "model_id_field": "deployment_name", + "package": "agent_framework.openai", + "name": "OpenAIChatClient", + "model_field": "model", + "endpoint_field": "azure_endpoint", + "api_key_field": "api_key", + }, + "Foundry": { + "package": "agent_framework.foundry", + "name": "FoundryChatClient", + "model_field": "model", + "endpoint_field": "project_endpoint", + "api_key_field": None, }, "OpenAI.Chat": { "package": "agent_framework.openai", - "name": "OpenAIChatClient", - "model_id_field": "model_id", - }, - "OpenAI.Assistants": { - "package": "agent_framework.openai", - "name": "OpenAIAssistantsClient", - "model_id_field": "model_id", + "name": "OpenAIChatCompletionClient", + "model_field": "model", + "endpoint_field": "base_url", + "api_key_field": "api_key", }, "OpenAI.Responses": { "package": "agent_framework.openai", - "name": "OpenAIResponsesClient", - "model_id_field": "model_id", + "name": "OpenAIChatClient", + "model_field": "model", + "endpoint_field": "base_url", + "api_key_field": "api_key", }, - "AzureAIAgentClient": { - "package": "agent_framework.azure", - "name": "AzureAIAgentClient", - "model_id_field": "model_deployment_name", + "OpenAI": { + "package": "agent_framework.openai", + "name": "OpenAIChatClient", + "model_field": "model", + "endpoint_field": "base_url", + "api_key_field": "api_key", }, - "AzureAIClient": { - "package": "agent_framework.azure", - "name": "AzureAIClient", - "model_id_field": "model_deployment_name", - }, - "AzureAI.ProjectProvider": { - "package": "agent_framework.azure", - "name": "AzureAIProjectAgentProvider", - "model_id_field": "model", + "Foundry.Chat": { + "package": "agent_framework.foundry", + "name": "FoundryChatClient", + "model_field": "model", + "endpoint_field": "project_endpoint", + "api_key_field": None, }, "Anthropic.Chat": { "package": "agent_framework.anthropic", "name": "AnthropicChatClient", - "model_id_field": "model_id", + "model_field": "model", + "endpoint_field": None, + "api_key_field": "api_key", }, } @@ -137,11 +152,11 @@ class AgentFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework_declarative import AgentFactory # With pre-configured chat client - client = AzureOpenAIChatClient() + client = OpenAIChatClient() factory = AgentFactory(client=client) agent = factory.create_agent_from_yaml_path("agent.yaml") @@ -171,7 +186,7 @@ class AgentFactory: connections: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, additional_mappings: Mapping[str, ProviderTypeMapping] | None = None, - default_provider: str = "AzureAIClient", + default_provider: str = "Foundry", safe_mode: bool = True, env_file_path: str | None = None, env_file_encoding: str | None = None, @@ -192,21 +207,23 @@ class AgentFactory: ..code-block:: python additional_mappings = { - "Provider.ApiType": { - "package": "package.name", - "name": "ClassName", - "model_id_field": "field_name_in_constructor", - }, - ... - } + "Provider.ApiType": { + "package": "package.name", + "name": "ClassName", + "model_field": "field_name_in_constructor", + "endpoint_field": "endpoint_kwarg_name_or_null", + "api_key_field": "api_key_kwarg_name_or_null", + }, + ... + } Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the model, "Provider" is also allowed. Package refers to which model needs to be imported, Name is the class name of the - SupportsChatGetResponse implementation, and model_id_field is the name of the field in the + SupportsChatGetResponse implementation, and model_field is the name of the field in the constructor that accepts the model.id value. default_provider: The default provider used when model.provider is not specified, - default is "AzureAIClient". + default is "Foundry", which uses the FoundryChatClient. safe_mode: Whether to run in safe mode, default is True. When safe_mode is True, environment variables are not accessible in the powerfx expressions. You can still use environment variables, but through the constructors of the classes. @@ -227,11 +244,11 @@ class AgentFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework_declarative import AgentFactory # With shared chat client - client = AzureOpenAIChatClient() + client = OpenAIChatClient() factory = AgentFactory( client=client, env_file_path=".env", @@ -247,7 +264,7 @@ class AgentFactory: "CustomProvider.Chat": { "package": "my_package.clients", "name": "CustomChatClient", - "model_id_field": "model_name", + "model_field": "model", }, }, ) @@ -457,8 +474,8 @@ class AgentFactory: async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent: """Async version: Create a Agent from a YAML file path. - Use this method when the provider requires async initialization, such as - AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. + This is the async counterpart to ``create_agent_from_dict`` and is useful when + the rest of your setup is already async. Args: yaml_path: Path to the YAML file representation of a PromptAgent. @@ -473,7 +490,7 @@ class AgentFactory: factory = AgentFactory( client_kwargs={"credential": credential}, - default_provider="AzureAI.ProjectProvider", + default_provider="Foundry", ) agent = await factory.create_agent_from_yaml_path_async("agent.yaml") """ @@ -487,8 +504,8 @@ class AgentFactory: async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent: """Async version: Create a Agent from a YAML string. - Use this method when the provider requires async initialization, such as - AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. + Use this method when the surrounding call site is already async and you + want to build an agent directly from YAML text. Args: yaml_str: YAML string representation of a PromptAgent. @@ -507,7 +524,7 @@ class AgentFactory: instructions: You are a helpful assistant. model: id: gpt-4o - provider: AzureAI.ProjectProvider + provider: Foundry ''' factory = AgentFactory(client_kwargs={"credential": credential}) @@ -518,8 +535,8 @@ class AgentFactory: async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent: """Async version: Create a Agent from a dictionary definition. - Use this method when the provider requires async initialization, such as - AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. + This is the async counterpart to ``create_agent_from_dict`` and is useful when + the rest of your setup is already async. Args: agent_def: Dictionary representation of a PromptAgent. @@ -538,7 +555,7 @@ class AgentFactory: "instructions": "You are a helpful assistant.", "model": { "id": "gpt-4o", - "provider": "AzureAI.ProjectProvider", + "provider": "Foundry", }, } @@ -551,12 +568,6 @@ class AgentFactory: if not isinstance(prompt_agent, PromptAgent): raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.") - # Check if we're using a provider-based approach (like AzureAIProjectAgentProvider) - mapping = self._retrieve_provider_configuration(prompt_agent.model) if prompt_agent.model else None - if mapping and mapping["name"] == "AzureAIProjectAgentProvider": - return await self._create_agent_with_provider(prompt_agent, mapping) - - # Fall back to standard ChatClient approach client = self._get_client(prompt_agent) chat_options = self._parse_chat_options(prompt_agent.model) if tools := self._parse_tools(prompt_agent.tools): @@ -572,48 +583,42 @@ class AgentFactory: ) async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent: - """Create a Agent using AzureAIProjectAgentProvider. + """Create an Agent through a provider object that exposes ``create_agent``. - This method handles the special case where we use a provider that creates - agents on a remote service (like Azure AI Agent Service) and returns - Agent instances directly. + This remains available as an internal escape hatch for provider-style custom mappings + that return a fully constructed ``Agent`` rather than a chat client. """ - # Import the provider class module_name = mapping["package"] class_name = mapping["name"] module = __import__(module_name, fromlist=[class_name]) provider_class = getattr(module, class_name) - # Build provider kwargs from client_kwargs and connection info provider_kwargs: dict[str, Any] = {} provider_kwargs.update(self.client_kwargs) - # Handle connection settings for the model + endpoint_field = mapping.get("endpoint_field") + api_key_field = mapping.get("api_key_field", "api_key") + if prompt_agent.model and prompt_agent.model.connection: match prompt_agent.model.connection: - case RemoteConnection() | AnonymousConnection(): - if prompt_agent.model.connection.endpoint: - provider_kwargs["project_endpoint"] = prompt_agent.model.connection.endpoint case ApiKeyConnection(): - if prompt_agent.model.connection.endpoint: - provider_kwargs["project_endpoint"] = prompt_agent.model.connection.endpoint + if api_key_field: + provider_kwargs[api_key_field] = prompt_agent.model.connection.apiKey + if prompt_agent.model.connection.endpoint and endpoint_field: + provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint + case RemoteConnection() | AnonymousConnection(): + if prompt_agent.model.connection.endpoint and endpoint_field: + provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint case ReferenceConnection(): - # Reference connections are resolved by concrete providers when supported. pass - # Create the provider and use it to create the agent provider = provider_class(**provider_kwargs) - - # Parse tools tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None - # Parse response format into default_options default_options: dict[str, Any] | None = None if prompt_agent.outputSchema: default_options = {"response_format": prompt_agent.outputSchema.to_json_schema()} - # Create the agent using the provider - # The provider's create_agent returns a Agent directly return cast( Agent, await provider.create_agent( @@ -637,18 +642,35 @@ class AgentFactory: "alternatively define a model in the PromptAgent." ) + mapping = self._retrieve_provider_configuration(prompt_agent.model) setup_dict: dict[str, Any] = {} setup_dict.update(self.client_kwargs) + endpoint_field = mapping.get("endpoint_field") + api_key_field = mapping.get("api_key_field", "api_key") # parse connections if prompt_agent.model.connection: match prompt_agent.model.connection: case ApiKeyConnection(): - setup_dict["api_key"] = prompt_agent.model.connection.apiKey + if api_key_field: + setup_dict[api_key_field] = prompt_agent.model.connection.apiKey + elif prompt_agent.model.connection.apiKey: + raise DeclarativeLoaderError( + f"{mapping['name']} does not support API key-based model connections." + ) if prompt_agent.model.connection.endpoint: - setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + if not endpoint_field: + raise DeclarativeLoaderError( + f"{mapping['name']} does not support endpoint-based model connections." + ) + setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint case RemoteConnection() | AnonymousConnection(): - setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + if prompt_agent.model.connection.endpoint: + if not endpoint_field: + raise DeclarativeLoaderError( + f"{mapping['name']} does not support endpoint-based model connections." + ) + setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint case ReferenceConnection(): if not self.connections: raise ValueError("Connections must be provided to resolve ReferenceConnection") @@ -668,17 +690,16 @@ class AgentFactory: # if prompt_agent.model is defined, but no id, use the supplied client if self.client: return self.client - # or raise, since we cannot create a client without model id + # or raise, since we cannot create a client without a model raise DeclarativeLoaderError( "ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent." ) # if provider is defined, use that, if possible with apiType, fallback to default_provider - mapping = self._retrieve_provider_configuration(prompt_agent.model) module_name = mapping["package"] class_name = mapping["name"] module = __import__(module_name, fromlist=[class_name]) agent_class = getattr(module, class_name) - setup_dict[mapping["model_id_field"]] = prompt_agent.model.id + setup_dict[mapping["model_field"]] = prompt_agent.model.id return agent_class(**setup_dict) # type: ignore[no-any-return] def _parse_chat_options(self, model: Model | None) -> dict[str, Any]: @@ -820,7 +841,7 @@ class AgentFactory: model: The Model instance containing provider and apiType information. Returns: - A dictionary containing the package, name, and model_id_field for the provider. + A dictionary containing the package, name, and model_field for the provider. Raises: ProviderLookupError: If the provider type is not supported or can't be found. diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index 02cc6dab11..8b479304cc 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -640,7 +640,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor): # Add user input to conversation history first (via state.append only) if input_text: - user_message = Message(role="user", text=input_text) + user_message = Message(role="user", contents=[input_text]) state.append(messages_path, user_message) # Get conversation history from state AFTER adding user message @@ -717,7 +717,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor): "Agent '%s': No messages in response, creating simple assistant message", agent_name, ) - assistant_message = Message(role="assistant", text=accumulated_response) + assistant_message = Message(role="assistant", contents=[accumulated_response]) state.append(messages_path, assistant_message) # Store results in state - support both schema formats: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py index 34396a85c2..b2c046a69b 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py @@ -588,7 +588,9 @@ class BaseToolExecutor(DeclarativeActionExecutor): messages=[ Message( role="assistant", - text=f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}", + contents=[ + f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}" + ], ) ], ) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index 4d6f3fa35b..9ba4cb84de 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -70,11 +70,11 @@ class WorkflowFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework.declarative import WorkflowFactory # Pre-register agents for InvokeAzureAgent actions - client = AzureOpenAIChatClient() + client = OpenAIChatClient() agent = client.as_agent(name="MyAgent", instructions="You are helpful.") factory = WorkflowFactory(agents={"MyAgent": agent}) @@ -116,11 +116,11 @@ class WorkflowFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework.declarative import WorkflowFactory # With pre-registered agents - client = AzureOpenAIChatClient() + client = OpenAIChatClient() agents = { "WriterAgent": client.as_agent(name="Writer", instructions="Write content."), "ReviewerAgent": client.as_agent(name="Reviewer", instructions="Review content."), @@ -535,10 +535,10 @@ class WorkflowFactory: Examples: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework.declarative import WorkflowFactory - client = AzureOpenAIChatClient() + client = OpenAIChatClient() # Method chaining to register multiple agents factory = ( diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 64bcd0a725..c502409de6 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 2ca87bfa65..db78d33315 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -439,10 +439,10 @@ properties: def _get_agent_sample_yaml_files() -> list[tuple[Path, Path]]: - """Helper function to collect all YAML files from agent-samples directory.""" + """Helper function to collect all YAML files from declarative-agents/agent-samples directory.""" current_file = Path(__file__) repo_root = current_file.parent.parent.parent.parent # tests -> declarative -> packages -> python - agent_samples_dir = repo_root.parent / "agent-samples" + agent_samples_dir = repo_root.parent / "declarative-agents" / "agent-samples" if not agent_samples_dir.exists(): return [] @@ -457,7 +457,7 @@ def _get_agent_sample_yaml_files() -> list[tuple[Path, Path]]: ids=lambda x: x[0].name if isinstance(x, tuple) else str(x), ) def test_agent_schema_dispatch_agent_samples(yaml_file: Path, agent_samples_dir: Path): - """Test that agent_schema_dispatch successfully loads a YAML file from agent-samples directory.""" + """Test that agent_schema_dispatch loads a YAML file from declarative-agents/agent-samples directory.""" with open(yaml_file) as f: content = f.read() result = agent_schema_dispatch(yaml.safe_load(content)) @@ -632,7 +632,7 @@ class TestAgentFactorySafeMode: from agent_framework_declarative._loader import AgentFactory - monkeypatch.setenv("TEST_MODEL_ID", "gpt-4-from-env") + monkeypatch.setenv("TEST_MODEL", "gpt-4-from-env") # Create a mock chat client to avoid needing real provider mock_client = MagicMock() @@ -1131,7 +1131,7 @@ model: "CustomProvider.Chat": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model_id", + "model_field": "model", }, } diff --git a/python/packages/declarative/tests/test_workflow_samples_integration.py b/python/packages/declarative/tests/test_workflow_samples_integration.py index d5ad65caab..9655f0772e 100644 --- a/python/packages/declarative/tests/test_workflow_samples_integration.py +++ b/python/packages/declarative/tests/test_workflow_samples_integration.py @@ -2,7 +2,7 @@ """Integration tests for workflow samples. -These tests verify that the workflow samples from workflow-samples/ directory +These tests verify that the workflow samples from declarative-agents/workflow-samples/ directory can be parsed and validated by the WorkflowFactory. """ @@ -13,7 +13,7 @@ import yaml # Path to workflow samples - navigate from tests dir up to repo root # tests/test_*.py -> packages/declarative/tests/ -> packages/declarative/ -> packages/ -> python/ -> repo root -WORKFLOW_SAMPLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "workflow-samples" +WORKFLOW_SAMPLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "declarative-agents" / "workflow-samples" def get_workflow_sample_files(): diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md index eca7272cfd..669e7cd4d4 100644 --- a/python/packages/devui/README.md +++ b/python/packages/devui/README.md @@ -69,11 +69,11 @@ Register cleanup hooks to properly close credentials and resources on shutdown: ```python from azure.identity.aio import DefaultAzureCredential from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_devui import register_cleanup, serve credential = DefaultAzureCredential() -client = AzureOpenAIChatClient() +client = OpenAIChatCompletionClient() agent = Agent(name="MyAgent", client=client) # Register cleanup hook - credential will be closed on shutdown diff --git a/python/packages/devui/agent_framework_devui/_conversations.py b/python/packages/devui/agent_framework_devui/_conversations.py index 8130835002..243f36cee3 100644 --- a/python/packages/devui/agent_framework_devui/_conversations.py +++ b/python/packages/devui/agent_framework_devui/_conversations.py @@ -314,7 +314,7 @@ class InMemoryConversationStore(ConversationStore): text_obj = first_content.get("text", "") text = text_obj if isinstance(text_obj, str) else str(text_obj) - chat_msg = Message(role=role, text=text) # type: ignore[arg-type] + chat_msg = Message(role=role, contents=[text]) # type: ignore[arg-type] chat_messages.append(chat_msg) # Add messages to internal storage diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index 372e870c15..f7b462933d 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -391,7 +391,7 @@ class EntityDiscovery: source=source, # IMPORTANT: Pass the source parameter tools=[str(tool) for tool in (tools_list or [])], instructions=instructions, - model_id=model, + model=model, chat_client_type=chat_client_type, context_provider=context_provider_list, middleware=middlewares_list, @@ -846,7 +846,7 @@ class EntityDiscovery: description=description, tools=tools_union, instructions=instructions, - model_id=model, + model=model, chat_client_type=chat_client_type, context_provider=context_provider_list, middleware=middlewares_list, diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index 9e79b308c5..07f87fec3f 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -770,9 +770,9 @@ class MessageMapper: from .models._openai_custom import AgentCompletedEvent, AgentFailedEvent, AgentStartedEvent try: - # Get model name from request or use 'devui' as default + # Get model from request or use 'devui' as default request_obj = context.get("request") - model_name = request_obj.model if request_obj and request_obj.model else "devui" + model = request_obj.model if request_obj and request_obj.model else "devui" if isinstance(event, AgentStartedEvent): execution_id = f"agent_{uuid4().hex[:12]}" @@ -783,7 +783,7 @@ class MessageMapper: id=f"resp_{execution_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], status="in_progress", parallel_tool_calls=False, @@ -818,7 +818,7 @@ class MessageMapper: id=f"resp_{execution_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], status="failed", error=response_error, @@ -865,16 +865,16 @@ class MessageMapper: # Return proper OpenAI event objects events: list[Any] = [] - # Get model name from request or use 'devui' as default + # Get model from request or use 'devui' as default request_obj = context.get("request") - model_name = request_obj.model if request_obj and request_obj.model else "devui" + model = request_obj.model if request_obj and request_obj.model else "devui" # Create a full Response object with all required fields response_obj = Response( id=f"resp_{workflow_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], # Empty output list initially status="in_progress", # Required fields with safe defaults @@ -989,9 +989,9 @@ class MessageMapper: # Import Response and ResponseError types from openai.types.responses import Response, ResponseError - # Get model name from request or use 'devui' as default + # Get model from request or use 'devui' as default request_obj = context.get("request") - model_name = request_obj.model if request_obj and request_obj.model else "devui" + model = request_obj.model if request_obj and request_obj.model else "devui" # Extract error message from WorkflowErrorDetails if details: @@ -1013,7 +1013,7 @@ class MessageMapper: id=f"resp_{workflow_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], status="failed", error=response_error, diff --git a/python/packages/devui/agent_framework_devui/_session.py b/python/packages/devui/agent_framework_devui/_session.py index 93ac9b31e4..9d657a2d30 100644 --- a/python/packages/devui/agent_framework_devui/_session.py +++ b/python/packages/devui/agent_framework_devui/_session.py @@ -20,7 +20,7 @@ class RequestRecord(TypedDict): entity_id: str executor: str input: Any - model_id: str + model: str stream: bool execution_time: NotRequired[float] status: NotRequired[str] @@ -91,7 +91,7 @@ class SessionManager: logger.debug(f"Closed session: {session_id}") def add_request_record( - self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model_id: str + self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model: str ) -> str: """Add a request record to a session. @@ -100,7 +100,7 @@ class SessionManager: entity_id: ID of the entity being executed executor_name: Name of the executor request_input: Input for the request - model_id: Model name + model: Model name Returns: Request ID @@ -115,7 +115,7 @@ class SessionManager: "entity_id": entity_id, "executor": executor_name, "input": request_input, - "model_id": model_id, + "model": model, "stream": True, } session["requests"].append(request_record) @@ -163,7 +163,7 @@ class SessionManager: "timestamp": req["timestamp"].isoformat(), "entity_id": req["entity_id"], "executor": req["executor"], - "model": req["model_id"], + "model": req["model"], "input_length": len(str(req["input"])) if req["input"] else 0, "execution_time": req.get("execution_time"), "status": req.get("status", "unknown"), diff --git a/python/packages/devui/agent_framework_devui/_utils.py b/python/packages/devui/agent_framework_devui/_utils.py index 889a690c87..602901995d 100644 --- a/python/packages/devui/agent_framework_devui/_utils.py +++ b/python/packages/devui/agent_framework_devui/_utils.py @@ -59,13 +59,13 @@ def extract_agent_metadata(entity_object: Any) -> dict[str, Any]: chat_opts = entity_object.default_options chat_opts_dict = _string_key_dict(chat_opts) if chat_opts_dict is not None: - model_id = chat_opts_dict.get("model_id") - if model_id: - metadata["model"] = model_id - elif hasattr(chat_opts, "model_id") and chat_opts.model_id: - metadata["model"] = chat_opts.model_id - if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model_id"): - metadata["model"] = entity_object.client.model_id + model = chat_opts_dict.get("model") + if model: + metadata["model"] = model + elif hasattr(chat_opts, "model") and chat_opts.model: + metadata["model"] = chat_opts.model + if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model"): + metadata["model"] = entity_object.client.model # Try to get chat client type if hasattr(entity_object, "client"): @@ -484,6 +484,43 @@ def parse_input_for_type(input_data: Any, target_type: type) -> Any: return input_data +def _build_message_from_legacy_payload(input_data: str | dict[str, Any]) -> Message: + """Convert raw DevUI input into a framework Message. + + This preserves DevUI compatibility for older payloads that still send + ``{"role": "...", "text": "..."}`` instead of the framework-native + ``{"role": "...", "contents": [...]}`` shape. + """ + if isinstance(input_data, str): + return Message(role="user", contents=[input_data]) + + role = input_data.get("role", "user") + role = role if isinstance(role, str) else str(role) + + if "contents" in input_data: + contents = input_data["contents"] + else: + contents = None + for field in ("text", "message", "content", "input", "data"): + if field in input_data: + contents = input_data[field] + break + + if contents is None: + contents_list: list[Any] = [] + elif isinstance(contents, list): + contents_list = contents # type: ignore[reportUnknownVariableType] + else: + contents_list = [contents] + + kwargs: dict[str, Any] = {} + for field in ("author_name", "message_id", "additional_properties", "raw_representation"): + if field in input_data: + kwargs[field] = input_data[field] + + return Message(role=role, contents=contents_list, **kwargs) + + def _parse_string_input(input_str: str, target_type: type) -> Any: """Parse string input to target type. @@ -531,6 +568,14 @@ def _parse_string_input(input_str: str, target_type: type) -> Any: # SerializationMixin (like Message) if is_serialization_mixin(target_type): try: + if target_type is Message: + if input_str.strip().startswith("{"): + data = json.loads(input_str) + parsed_dict = _string_key_dict(data) + if parsed_dict is not None: + return _build_message_from_legacy_payload(parsed_dict) + return _build_message_from_legacy_payload(input_str) + # Try parsing as JSON dict first if input_str.strip().startswith("{"): data = json.loads(input_str) @@ -538,20 +583,10 @@ def _parse_string_input(input_str: str, target_type: type) -> Any: return target_type.from_dict(data) # type: ignore return target_type(**data) # type: ignore - # For Message specifically: create from text - # Try common field patterns + # Try other common fields common_fields = ["text", "message", "content"] sig = inspect.signature(target_type) params = list(sig.parameters.keys()) - - # If it has 'text' param, use it - if "text" in params: - try: - return target_type(role="user", text=input_str) # type: ignore - except Exception as e: - logger.debug(f"Failed to create SerializationMixin with text field: {e}") - - # Try other common fields for field in common_fields: if field in params: try: @@ -631,6 +666,8 @@ def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any: # SerializationMixin if is_serialization_mixin(target_type): try: + if target_type is Message: + return _build_message_from_legacy_payload(input_dict) if hasattr(target_type, "from_dict"): return target_type.from_dict(input_dict) # type: ignore return target_type(**input_dict) # type: ignore diff --git a/python/packages/devui/agent_framework_devui/models/_discovery_models.py b/python/packages/devui/agent_framework_devui/models/_discovery_models.py index 1e1f19e04d..694f871a7d 100644 --- a/python/packages/devui/agent_framework_devui/models/_discovery_models.py +++ b/python/packages/devui/agent_framework_devui/models/_discovery_models.py @@ -44,7 +44,7 @@ class EntityInfo(BaseModel): # Agent-specific fields (optional, populated when available) instructions: str | None = None - model_id: str | None = None + model: str | None = None chat_client_type: str | None = None context_provider: list[str] | None = None middleware: list[str] | None = None diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index a71e62397f..fe3c02b6ee 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -481,11 +481,11 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} + - OPENAI_CHAT_COMPLETION_MODEL=\${OPENAI_CHAT_COMPLETION_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} - - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME} + - AZURE_OPENAI_MODEL=\${AZURE_OPENAI_MODEL} # Optional: Enable instrumentation - ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false} ports: @@ -514,12 +514,12 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`})] + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_COMPLETION_MODEL=gpt-4o-mini`})] }), o.jsxs("div", { className: "border-l-2 border-primary pl-3", children: [o.jsxs("div", { className: "flex items-center gap-2 mb-1", children: [o.jsx("div", { className: "w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold", children: "5" }), o.jsx("h5", { className: "font-medium text-sm", children: "Get Application URL" })] }), o.jsx("pre", { className: "bg-muted p-2 rounded text-xs overflow-x-auto border mt-2", children: `az containerapp show --name ${r.toLowerCase()}-app \\ --resource-group myResourceGroup \\ - --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"AZURE_AI_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} + --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"FOUNDRY_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_MODEL",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} mv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),a&&o.jsx(iu,{number:3,title:"Set up environment variables",description:"Create a .env file in the project folder with these required variables:",code:e.requiredEnvVars.map(c=>`${c.name}=${c.example||"your-value-here"} # ${c.description}`).join(` diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 0566e75429..d537c22ca7 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -33,11 +33,11 @@ Then edit `.env` and add your API keys: ```bash # For OpenAI (minimum required) OPENAI_API_KEY="your-api-key-here" -OPENAI_CHAT_MODEL="gpt-4o-mini" +OPENAI_CHAT_COMPLETION_MODEL="gpt-4o-mini" # Or for Azure OpenAI AZURE_OPENAI_ENDPOINT="your-endpoint" -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" +AZURE_OPENAI_MODEL="your-deployment-name" ``` ## 4. Test DevUI diff --git a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx index dd5ae81180..14c684bec2 100644 --- a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx +++ b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx @@ -243,11 +243,11 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} + - OPENAI_CHAT_COMPLETION_MODEL=\${OPENAI_CHAT_COMPLETION_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} - - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME} + - AZURE_OPENAI_MODEL=\${AZURE_OPENAI_MODEL} # Optional: Enable instrumentation - ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false} ports: @@ -802,7 +802,7 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`} + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_COMPLETION_MODEL=gpt-4o-mini`} diff --git a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts index 64211ddfcb..9509562dfc 100644 --- a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts +++ b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts @@ -41,13 +41,13 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ ], requiredEnvVars: [ { - name: "AZURE_AI_PROJECT_ENDPOINT", + name: "FOUNDRY_PROJECT_ENDPOINT", description: "Azure AI Foundry project endpoint URL", required: true, example: "https://your-project.api.azureml.ms", }, { - name: "FOUNDRY_MODEL_DEPLOYMENT_NAME", + name: "FOUNDRY_MODEL", description: "Name of the deployed model in Azure AI Foundry", required: true, example: "gpt-4o", @@ -78,7 +78,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ required: true, }, { - name: "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + name: "AZURE_OPENAI_MODEL", description: "Name of the deployed model in Azure OpenAI", required: true, example: "gpt-4o", diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 1de35bd0cb..fd3bc62fe2 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "openai>=1.99.0,<3", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.133.1", @@ -34,7 +34,7 @@ classifiers = [ dev = [ "pytest==9.0.2", "watchdog==6.0.0", - "agent-framework-orchestrations==1.0.0b260304", + "agent-framework-orchestrations==1.0.0b260402", ] all = [ "pytest==9.0.2", diff --git a/python/packages/devui/samples/README.md b/python/packages/devui/samples/README.md deleted file mode 100644 index d4ad6f6b83..0000000000 --- a/python/packages/devui/samples/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# DevUI Samples - Moved - -**The DevUI samples have been relocated to the main samples folder for better consistency and discoverability.** - -## New Location - -All DevUI samples are now located at: - -``` -python/samples/02-agents/devui/ -``` - -## Available Samples - -- **weather_agent** - Basic OpenAI weather agent -- **weather_agent_azure** - Azure OpenAI weather agent -- **foundry_agent** - Azure AI Foundry weather agent -- **spam_workflow** - Email spam detection workflow -- **fanout_workflow** - Complex fan-in/fan-out data processing workflow -- **in_memory_mode.py** - In-memory entity registration example - -## Quick Start - -```bash -cd ../../samples/02-agents/devui -python in_memory_mode.py -``` - -Or for directory discovery: - -```bash -cd ../../samples/02-agents/devui -devui -``` - -## Learn More - -See the [DevUI samples README](../../../samples/02-agents/devui/README.md) for detailed documentation. diff --git a/python/packages/devui/samples/__init__.py b/python/packages/devui/samples/__init__.py deleted file mode 100644 index 50801e55c7..0000000000 --- a/python/packages/devui/samples/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Examples package for Agent Framework DevUI.""" diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 8fbc127946..3f1945be3a 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -151,7 +151,7 @@ async def test_credential_cleanup() -> None: # Create mock chat client with credential mock_client = Mock() mock_client.async_credential = mock_credential - mock_client.model_id = "test-model" + mock_client.model = "test-model" mock_client.function_invocation_configuration = None # Create agent with mock client @@ -184,7 +184,7 @@ async def test_credential_cleanup_error_handling() -> None: # Create mock chat client with credential mock_client = Mock() mock_client.async_credential = mock_credential - mock_client.model_id = "test-model" + mock_client.model = "test-model" mock_client.function_invocation_configuration = None # Create agent with mock client @@ -219,7 +219,7 @@ async def test_multiple_credential_attributes() -> None: mock_client = Mock() mock_client.credential = mock_cred1 mock_client.async_credential = mock_cred2 - mock_client.model_id = "test-model" + mock_client.model = "test-model" mock_client.function_invocation_configuration = None # Create agent with mock client diff --git a/python/packages/durabletask/AGENTS.md b/python/packages/durabletask/AGENTS.md index e0b1be1d19..1da199f5f3 100644 --- a/python/packages/durabletask/AGENTS.md +++ b/python/packages/durabletask/AGENTS.md @@ -30,7 +30,7 @@ Durable execution support for long-running agent workflows using Azure Durable F ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_durabletask import DurableAIAgentClient, DurableAIAgentWorker from durabletask.client import TaskHubGrpcClient from durabletask.worker import TaskHubGrpcWorker @@ -45,7 +45,7 @@ dt_worker = TaskHubGrpcWorker(host_address="localhost:4001") agent_worker = DurableAIAgentWorker(dt_worker) # Create a chat client for the agent -chat_client = AzureOpenAIChatClient() +chat_client = OpenAIChatCompletionClient() my_agent = Agent(client=chat_client, name="assistant") agent_worker.add_agent(my_agent) ``` diff --git a/python/packages/durabletask/README.md b/python/packages/durabletask/README.md index 5447d19cea..7758c741bf 100644 --- a/python/packages/durabletask/README.md +++ b/python/packages/durabletask/README.md @@ -16,7 +16,7 @@ The durable task integration lets you host Microsoft Agent Framework agents usin ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_durabletask import DurableAIAgentWorker from durabletask.worker import TaskHubGrpcWorker @@ -24,7 +24,7 @@ from durabletask.worker import TaskHubGrpcWorker worker = TaskHubGrpcWorker(host_address="localhost:4001") agent_worker = DurableAIAgentWorker(worker) -chat_client = AzureOpenAIChatClient() +chat_client = OpenAIChatCompletionClient() my_agent = Agent(client=chat_client, name="assistant") agent_worker.add_agent(my_agent) ``` diff --git a/python/packages/durabletask/agent_framework_durabletask/_worker.py b/python/packages/durabletask/agent_framework_durabletask/_worker.py index e9670fd3cf..728ae17629 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_worker.py +++ b/python/packages/durabletask/agent_framework_durabletask/_worker.py @@ -31,7 +31,7 @@ class DurableAIAgentWorker: ```python from durabletask.worker import TaskHubGrpcWorker from agent_framework import Agent - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_durabletask import DurableAIAgentWorker # Create the underlying worker @@ -41,7 +41,7 @@ class DurableAIAgentWorker: agent_worker = DurableAIAgentWorker(worker) # Register agents - client = AzureOpenAIChatClient() + client = OpenAIChatCompletionClient() my_agent = Agent(client=client, name="assistant") agent_worker.add_agent(my_agent) diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index f2fe9af94b..a7c325442c 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -4,7 +4,7 @@ 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.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", @@ -30,7 +30,7 @@ dependencies = [ [dependency-groups] dev = [ - "types-python-dateutil==2.9.0.20260305", + "types-python-dateutil==2.9.0.20260402", ] [tool.uv] diff --git a/python/packages/durabletask/tests/integration_tests/.env.example b/python/packages/durabletask/tests/integration_tests/.env.example index 4e5abff232..1944e8999c 100644 --- a/python/packages/durabletask/tests/integration_tests/.env.example +++ b/python/packages/durabletask/tests/integration_tests/.env.example @@ -1,6 +1,6 @@ # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_MODEL=your-deployment-name # Optional: Use Azure CLI authentication if not provided # AZURE_OPENAI_API_KEY=your-api-key diff --git a/python/packages/durabletask/tests/integration_tests/README.md b/python/packages/durabletask/tests/integration_tests/README.md index 5cce6712e4..704458a492 100644 --- a/python/packages/durabletask/tests/integration_tests/README.md +++ b/python/packages/durabletask/tests/integration_tests/README.md @@ -14,7 +14,7 @@ cp .env.example .env Required variables: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` - `AZURE_OPENAI_API_KEY` (optional if using Azure CLI authentication) - `ENDPOINT` (default: http://localhost:8080) - `TASKHUB` (default: default) @@ -97,7 +97,7 @@ If you see "DTS emulator is not available": If you see authentication or deployment errors: - Verify your `AZURE_OPENAI_ENDPOINT` is correct -- Confirm `AZURE_OPENAI_DEPLOYMENT_NAME` matches your deployment +- 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` diff --git a/python/packages/durabletask/tests/integration_tests/conftest.py b/python/packages/durabletask/tests/integration_tests/conftest.py index 94fce45109..65202e2907 100644 --- a/python/packages/durabletask/tests/integration_tests/conftest.py +++ b/python/packages/durabletask/tests/integration_tests/conftest.py @@ -291,7 +291,7 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item """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_DEPLOYMENT_NAME"] + 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( @@ -348,7 +348,7 @@ def check_sample_env(request: pytest.FixtureRequest) -> None: sample_name = cast(str, sample_marker.args[0]) # type: ignore[union-attr] if sample_name == "06_multi_agent_orchestration_conditionals": - required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"] + 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)] diff --git a/python/packages/durabletask/tests/test_durable_entities.py b/python/packages/durabletask/tests/test_durable_entities.py index e61eacaf0c..d237829794 100644 --- a/python/packages/durabletask/tests/test_durable_entities.py +++ b/python/packages/durabletask/tests/test_durable_entities.py @@ -83,7 +83,9 @@ def _role_value(chat_message: DurableAgentStateMessage) -> str: def _agent_response(text: str | None) -> AgentResponse: """Create an AgentResponse with a single assistant message.""" - message = Message(role="assistant", text=text) if text is not None else Message(role="assistant", text="") + 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") diff --git a/python/packages/durabletask/tests/test_shim.py b/python/packages/durabletask/tests/test_shim.py index 687a0746a7..9a265ab974 100644 --- a/python/packages/durabletask/tests/test_shim.py +++ b/python/packages/durabletask/tests/test_shim.py @@ -77,7 +77,7 @@ class TestDurableAIAgentMessageNormalization: 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", text="Test message") + chat_msg = Message(role="user", contents=["Test message"]) test_agent.run(chat_msg) mock_executor.run_durable_agent.assert_called_once() @@ -95,8 +95,8 @@ class TestDurableAIAgentMessageNormalization: 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", text="Message 1"), - Message(role="assistant", text="Message 2"), + Message(role="user", contents=["Message 1"]), + Message(role="assistant", contents=["Message 2"]), ] test_agent.run(messages) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index abaa0cb9c3..e22fb523a5 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -1,3 +1,3 @@ # Agent Framework Foundry -This package contains the cloud Azure AI Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, and Foundry memory providers. +This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers. diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index a67b5df801..fbd1376735 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -4,6 +4,12 @@ import importlib.metadata from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient +from ._embedding_client import ( + FoundryEmbeddingClient, + FoundryEmbeddingOptions, + FoundryEmbeddingSettings, + RawFoundryEmbeddingClient, +) from ._foundry_evals import ( FoundryEvals, evaluate_foundry_target, @@ -20,11 +26,15 @@ __all__ = [ "FoundryAgent", "FoundryChatClient", "FoundryChatOptions", + "FoundryEmbeddingClient", + "FoundryEmbeddingOptions", + "FoundryEmbeddingSettings", "FoundryEvals", "FoundryMemoryProvider", "RawFoundryAgent", "RawFoundryAgentChatClient", "RawFoundryChatClient", + "RawFoundryEmbeddingClient", "__version__", "evaluate_foundry_target", "evaluate_traces", diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 6f548b4012..a499abf6f4 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -17,9 +17,9 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, AgentMiddlewareLayer, - BaseContextProvider, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, @@ -50,8 +50,8 @@ else: if TYPE_CHECKING: from agent_framework import ( Agent, - BaseContextProvider, ChatAndFunctionMiddlewareTypes, + ContextProvider, MiddlewareTypes, ToolTypes, ) @@ -224,8 +224,9 @@ class RawFoundryAgentChatClient( # type: ignore[misc] instructions: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -246,6 +247,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] tools=function_tools, context_providers=context_providers, middleware=middleware, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, client_type=cast(type[RawFoundryAgentChatClient], self.__class__), id=id, name=self.agent_name if name is None else name, @@ -468,7 +470,7 @@ class RawFoundryAgent( # type: ignore[misc] project_client: AIProjectClient | None = None, allow_preview: bool | None = None, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, @@ -478,6 +480,7 @@ class RawFoundryAgent( # type: ignore[misc] description: str | None = None, instructions: str | None = None, default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -507,6 +510,8 @@ class RawFoundryAgent( # type: ignore[misc] description: Optional local description for the local agent wrapper. instructions: Optional instructions for the local agent wrapper. default_options: Default chat options for the local agent wrapper. + require_per_service_call_history_persistence: Whether to require per-service-call + chat history persistence when using local history providers. function_invocation_configuration: Optional function invocation configuration override. compaction_strategy: Optional agent-level in-run compaction override. tokenizer: Optional agent-level tokenizer override. @@ -548,6 +553,7 @@ class RawFoundryAgent( # type: ignore[misc] default_options=cast(FoundryAgentOptionsT | None, default_options), context_providers=context_providers, middleware=middleware, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, compaction_strategy=compaction_strategy, tokenizer=tokenizer, additional_properties=dict(additional_properties) if additional_properties is not None else None, @@ -661,7 +667,7 @@ class FoundryAgent( # type: ignore[misc] project_client: AIProjectClient | None = None, allow_preview: bool | None = None, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, @@ -671,6 +677,7 @@ class FoundryAgent( # type: ignore[misc] description: str | None = None, instructions: str | None = None, default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -696,6 +703,8 @@ class FoundryAgent( # type: ignore[misc] description: Optional local description for the local agent wrapper. instructions: Optional instructions for the local agent wrapper. default_options: Default chat options for the local agent wrapper. + require_per_service_call_history_persistence: Whether to require per-service-call + chat history persistence when using local history providers. function_invocation_configuration: Optional function invocation configuration override. compaction_strategy: Optional agent-level in-run compaction override. tokenizer: Optional agent-level tokenizer override. @@ -719,6 +728,7 @@ class FoundryAgent( # type: ignore[misc] description=description, instructions=instructions, default_options=default_options, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, function_invocation_configuration=function_invocation_configuration, compaction_strategy=compaction_strategy, tokenizer=tokenizer, diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 4634ec8524..b8ae5ce398 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -477,15 +477,14 @@ class FoundryChatClient( # type: ignore[misc] - ``FOUNDRY_MODEL`` to provide the Foundry model deployment name. Keyword Args: - project_endpoint: The Foundry project endpoint URL. - Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``. - project_client: An existing AIProjectClient to use. - model: The model deployment name. - Can also be set via environment variable ``FOUNDRY_MODEL``. - model_id: Deprecated alias for ``model``. - credential: Azure credential or token provider for authentication. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - env_file_path: Path to .env file for settings. + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``. + project_client: An existing AIProjectClient to use. + model: The model deployment name. + Can also be set via environment variable ``FOUNDRY_MODEL``. + credential: Azure credential or token provider for authentication. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. instruction_role: The role to use for 'instruction' messages. middleware: Optional sequence of middleware. diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py b/python/packages/foundry/agent_framework_foundry/_embedding_client.py similarity index 69% rename from python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py rename to python/packages/foundry/agent_framework_foundry/_embedding_client.py index 3daa678333..2e63bf7f56 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py +++ b/python/packages/foundry/agent_framework_foundry/_embedding_client.py @@ -28,23 +28,23 @@ else: from typing_extensions import TypeVar # type: ignore # pragma: no cover -logger = logging.getLogger("agent_framework.azure_ai") +logger = logging.getLogger("agent_framework.foundry") _IMAGE_MEDIA_PREFIXES = ("image/",) -class AzureAIInferenceEmbeddingOptions(EmbeddingGenerationOptions, total=False): - """Azure AI Inference-specific embedding options. +class FoundryEmbeddingOptions(EmbeddingGenerationOptions, total=False): + """Foundry inference-specific embedding options. - Extends EmbeddingGenerationOptions with Azure AI Inference-specific fields. + Extends ``EmbeddingGenerationOptions`` with Foundry inference-specific fields. Examples: .. code-block:: python - from agent_framework_azure_ai import AzureAIInferenceEmbeddingOptions + from agent_framework_foundry import FoundryEmbeddingOptions - options: AzureAIInferenceEmbeddingOptions = { - "model_id": "text-embedding-3-small", + options: FoundryEmbeddingOptions = { + "model": "text-embedding-3-small", "dimensions": 1536, "input_type": "document", "encoding_format": "float", @@ -54,8 +54,8 @@ class AzureAIInferenceEmbeddingOptions(EmbeddingGenerationOptions, total=False): input_type: str """Input type hint for the model. Common values: ``"text"``, ``"query"``, ``"document"``.""" - image_model_id: str - """Override model for image embeddings. Falls back to the client's ``image_model_id``.""" + image_model: str + """Override model for image embeddings. Falls back to the client's ``image_model``.""" encoding_format: str """Output encoding format. @@ -68,28 +68,28 @@ class AzureAIInferenceEmbeddingOptions(EmbeddingGenerationOptions, total=False): """Additional model-specific parameters passed directly to the API.""" -AzureAIInferenceEmbeddingOptionsT = TypeVar( - "AzureAIInferenceEmbeddingOptionsT", +FoundryEmbeddingOptionsT = TypeVar( + "FoundryEmbeddingOptionsT", bound=TypedDict, # type: ignore[valid-type] - default="AzureAIInferenceEmbeddingOptions", + default="FoundryEmbeddingOptions", covariant=True, ) -class AzureAIInferenceEmbeddingSettings(TypedDict, total=False): - """Azure AI Inference embedding settings.""" +class FoundryEmbeddingSettings(TypedDict, total=False): + """Foundry inference embedding settings.""" - endpoint: str | None - api_key: str | None - embedding_model_id: str | None - image_embedding_model_id: str | None + models_endpoint: str | None + models_api_key: str | None + embedding_model: str | None + image_embedding_model: str | None -class RawAzureAIInferenceEmbeddingClient( - BaseEmbeddingClient[Content | str, list[float], AzureAIInferenceEmbeddingOptionsT], - Generic[AzureAIInferenceEmbeddingOptionsT], +class RawFoundryEmbeddingClient( + BaseEmbeddingClient[Content | str, list[float], FoundryEmbeddingOptionsT], + Generic[FoundryEmbeddingOptionsT], ): - """Raw Azure AI Inference embedding client without telemetry. + """Raw Foundry embedding client without telemetry. Accepts both text (``str``) and image (``Content``) inputs. Text and image inputs within a single batch are separated and dispatched to @@ -97,15 +97,15 @@ class RawAzureAIInferenceEmbeddingClient( are reassembled in the original input order. Keyword Args: - model_id: The text embedding model deployment name (e.g. "text-embedding-3-small"). - Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID. - image_model_id: The image embedding model deployment name (e.g. "Cohere-embed-v3-english"). - Can also be set via environment variable AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID. - Falls back to ``model_id`` if not provided. - endpoint: The Azure AI Inference endpoint URL. - Can also be set via environment variable AZURE_AI_INFERENCE_ENDPOINT. + model: The text embedding model (e.g. "text-embedding-3-small"). + Can also be set via environment variable FOUNDRY_EMBEDDING_MODEL. + image_model: The image embedding model (e.g. "Cohere-embed-v3-english"). + Can also be set via environment variable FOUNDRY_IMAGE_EMBEDDING_MODEL. + Falls back to ``model`` if not provided. + endpoint: The Foundry inference endpoint URL. + Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT. api_key: API key for authentication. - Can also be set via environment variable AZURE_AI_INFERENCE_API_KEY. + Can also be set via environment variable FOUNDRY_MODELS_API_KEY. text_client: Optional pre-configured ``EmbeddingsClient``. image_client: Optional pre-configured ``ImageEmbeddingsClient``. credential: Optional ``AzureKeyCredential`` or token credential. If not provided, @@ -117,8 +117,8 @@ class RawAzureAIInferenceEmbeddingClient( def __init__( self, *, - model_id: str | None = None, - image_model_id: str | None = None, + model: str | None = None, + image_model: str | None = None, endpoint: str | None = None, api_key: str | None = None, text_client: EmbeddingsClient | None = None, @@ -128,25 +128,25 @@ class RawAzureAIInferenceEmbeddingClient( env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize a raw Azure AI Inference embedding client.""" + """Initialize a raw Foundry embedding client.""" settings = load_settings( - AzureAIInferenceEmbeddingSettings, - env_prefix="AZURE_AI_INFERENCE_", - required_fields=["endpoint", "embedding_model_id"], - endpoint=endpoint, - api_key=api_key, - embedding_model_id=model_id, - image_embedding_model_id=image_model_id, + FoundryEmbeddingSettings, + env_prefix="FOUNDRY_", + required_fields=["models_endpoint", "embedding_model"], + models_endpoint=endpoint, + models_api_key=api_key, + embedding_model=model, + image_embedding_model=image_model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - self.model_id = settings["embedding_model_id"] # type: ignore[reportTypedDictNotRequiredAccess] - self.image_model_id: str = settings.get("image_embedding_model_id") or self.model_id # type: ignore[assignment] - resolved_endpoint = settings["endpoint"] # type: ignore[reportTypedDictNotRequiredAccess] + self.model = settings["embedding_model"] # type: ignore[reportTypedDictNotRequiredAccess] + self.image_model: str = settings.get("image_embedding_model") or self.model # type: ignore[assignment] + resolved_endpoint = settings["models_endpoint"] # type: ignore[reportTypedDictNotRequiredAccess] - if credential is None and settings.get("api_key"): - credential = AzureKeyCredential(settings["api_key"]) # type: ignore[arg-type] + if credential is None and settings.get("models_api_key"): + credential = AzureKeyCredential(settings["models_api_key"]) # type: ignore[arg-type] if credential is None and text_client is None and image_client is None: raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.") @@ -169,7 +169,7 @@ class RawAzureAIInferenceEmbeddingClient( with suppress(Exception): await self._image_client.close() - async def __aenter__(self) -> RawAzureAIInferenceEmbeddingClient[AzureAIInferenceEmbeddingOptionsT]: + async def __aenter__(self) -> RawFoundryEmbeddingClient[FoundryEmbeddingOptionsT]: """Enter the async context manager.""" return self @@ -185,8 +185,8 @@ class RawAzureAIInferenceEmbeddingClient( self, values: Sequence[Content | str], *, - options: AzureAIInferenceEmbeddingOptionsT | None = None, - ) -> GeneratedEmbeddings[list[float], AzureAIInferenceEmbeddingOptionsT]: + options: FoundryEmbeddingOptionsT | None = None, + ) -> GeneratedEmbeddings[list[float], FoundryEmbeddingOptionsT]: """Generate embeddings for text and/or image inputs. Text inputs (``str`` or ``Content`` with ``type="text"``) are sent to the @@ -202,7 +202,7 @@ class RawAzureAIInferenceEmbeddingClient( Generated embeddings with usage metadata. Raises: - ValueError: If model_id is not provided or an unsupported content type is encountered. + ValueError: If model is not provided or an unsupported content type is encountered. """ if not values: return GeneratedEmbeddings([], options=options) # type: ignore[reportReturnType] @@ -254,8 +254,8 @@ class RawAzureAIInferenceEmbeddingClient( # Embed text inputs. if text_items: - if not (text_model := opts.get("model_id") or self.model_id): - raise ValueError("An model_id is required, either in the client or options, for text inputs.") + if not (text_model := opts.get("model") or self.model): + raise ValueError("A model is required, either in the client or options, for text inputs.") text_inputs = [t for _, t in text_items] response = await self._text_client.embed( input=text_inputs, @@ -268,7 +268,7 @@ class RawAzureAIInferenceEmbeddingClient( embeddings[original_idx] = Embedding( vector=vector, dimensions=len(vector), - model_id=response.model or text_model, + model=response.model or text_model, ) if response.usage: usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( @@ -280,8 +280,8 @@ class RawAzureAIInferenceEmbeddingClient( # Embed image inputs. if image_items: - if not (image_model := opts.get("image_model_id") or self.image_model_id): - raise ValueError("An image_model_id is required, either in the client or options, for image inputs.") + if not (image_model := opts.get("image_model") or self.image_model): + raise ValueError("An image_model is required, either in the client or options, for image inputs.") image_inputs = [img for _, img in image_items] response = await self._image_client.embed( input=image_inputs, @@ -294,7 +294,7 @@ class RawAzureAIInferenceEmbeddingClient( embeddings[original_idx] = Embedding( vector=image_vector, dimensions=len(image_vector), - model_id=response.model or image_model, + model=response.model or image_model, ) if response.usage: usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( @@ -310,27 +310,27 @@ class RawAzureAIInferenceEmbeddingClient( ) # type: ignore[reportReturnType] -class AzureAIInferenceEmbeddingClient( - EmbeddingTelemetryLayer[Content | str, list[float], AzureAIInferenceEmbeddingOptionsT], - RawAzureAIInferenceEmbeddingClient[AzureAIInferenceEmbeddingOptionsT], - Generic[AzureAIInferenceEmbeddingOptionsT], +class FoundryEmbeddingClient( + EmbeddingTelemetryLayer[Content | str, list[float], FoundryEmbeddingOptionsT], + RawFoundryEmbeddingClient[FoundryEmbeddingOptionsT], + Generic[FoundryEmbeddingOptionsT], ): - """Azure AI Inference embedding client with telemetry support. + """Foundry embedding client with telemetry support. Supports both text and image inputs in a single client. Pass plain strings or ``Content`` instances created with ``Content.from_text()`` or ``Content.from_data()``. Keyword Args: - model_id: The text embedding model deployment name (e.g. "text-embedding-3-small"). - Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID. - image_model_id: The image embedding model deployment name + model: The text embedding model (e.g. "text-embedding-3-small"). + Can also be set via environment variable FOUNDRY_EMBEDDING_MODEL. + image_model: The image embedding model (e.g. "Cohere-embed-v3-english"). Can also be set via environment variable - AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID. Falls back to ``model_id``. - endpoint: The Azure AI Inference endpoint URL. - Can also be set via environment variable AZURE_AI_INFERENCE_ENDPOINT. + FOUNDRY_IMAGE_EMBEDDING_MODEL. Falls back to ``model``. + endpoint: The Foundry inference endpoint URL. + Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT. api_key: API key for authentication. - Can also be set via environment variable AZURE_AI_INFERENCE_API_KEY. + Can also be set via environment variable FOUNDRY_MODELS_API_KEY. text_client: Optional pre-configured ``EmbeddingsClient``. image_client: Optional pre-configured ``ImageEmbeddingsClient``. credential: Optional ``AzureKeyCredential`` or token credential. @@ -341,14 +341,14 @@ class AzureAIInferenceEmbeddingClient( Examples: .. code-block:: python - from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient + from agent_framework_foundry import FoundryEmbeddingClient # Using environment variables - # Set AZURE_AI_INFERENCE_ENDPOINT=https://your-endpoint.inference.ai.azure.com - # Set AZURE_AI_INFERENCE_API_KEY=your-key - # Set AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID=text-embedding-3-small - # Set AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID=Cohere-embed-v3-english - client = AzureAIInferenceEmbeddingClient() + # Set FOUNDRY_MODELS_ENDPOINT=https://your-endpoint.inference.ai.azure.com + # Set FOUNDRY_MODELS_API_KEY=your-key + # Set FOUNDRY_EMBEDDING_MODEL=text-embedding-3-small + # Set FOUNDRY_IMAGE_EMBEDDING_MODEL=Cohere-embed-v3-english + client = FoundryEmbeddingClient() # Text embeddings result = await client.get_embeddings(["Hello, world!"]) @@ -368,8 +368,8 @@ class AzureAIInferenceEmbeddingClient( def __init__( self, *, - model_id: str | None = None, - image_model_id: str | None = None, + model: str | None = None, + image_model: str | None = None, endpoint: str | None = None, api_key: str | None = None, text_client: EmbeddingsClient | None = None, @@ -380,10 +380,10 @@ class AzureAIInferenceEmbeddingClient( env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize an Azure AI Inference embedding client.""" + """Initialize a Foundry embedding client.""" super().__init__( - model_id=model_id, - image_model_id=image_model_id, + model=model, + image_model=image_model, endpoint=endpoint, api_key=api_key, text_client=text_client, diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py index 697941bbfc..a5033a8e87 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py +++ b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py @@ -40,6 +40,7 @@ from agent_framework._evaluation import ( EvalResults, EvalScoreResult, ) +from agent_framework._feature_stage import ExperimentalFeature, experimental from openai import AsyncOpenAI from ._chat_client import FoundryChatClient @@ -491,6 +492,7 @@ async def _evaluate_via_responses_impl( # --------------------------------------------------------------------------- +@experimental(feature_id=ExperimentalFeature.EVALS) class FoundryEvals: """Evaluation provider backed by Microsoft Foundry. @@ -727,6 +729,7 @@ class FoundryEvals: # --------------------------------------------------------------------------- +@experimental(feature_id=ExperimentalFeature.EVALS) async def evaluate_traces( *, evaluators: Sequence[str] | None = None, @@ -817,6 +820,7 @@ async def evaluate_traces( return await _poll_eval_run(oai_client, eval_obj.id, run.id, poll_interval, timeout) +@experimental(feature_id=ExperimentalFeature.EVALS) async def evaluate_foundry_target( *, target: dict[str, Any], diff --git a/python/packages/foundry/agent_framework_foundry/_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_memory_provider.py index 36d4a27a43..c49f950b6b 100644 --- a/python/packages/foundry/agent_framework_foundry/_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_memory_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""Foundry Memory Context Provider using BaseContextProvider. +"""Foundry Memory Context Provider using ContextProvider. This module provides ``FoundryMemoryProvider``, built on -:class:`BaseContextProvider`. +:class:`ContextProvider`. """ from __future__ import annotations @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, AgentSession, - BaseContextProvider, + ContextProvider, Message, SessionContext, load_settings, @@ -46,8 +46,8 @@ class FoundryProjectSettings(TypedDict, total=False): project_endpoint: str | None -class FoundryMemoryProvider(BaseContextProvider): - """Foundry Memory context provider using the new BaseContextProvider hooks pattern. +class FoundryMemoryProvider(ContextProvider): + """Foundry Memory context provider using the new ContextProvider hooks pattern. Integrates Azure AI Foundry Memory Store for persistent semantic memory, searching and storing memories via the Azure AI Projects SDK. @@ -219,7 +219,7 @@ class FoundryMemoryProvider(BaseContextProvider): if line_separated_memories: context.extend_messages( self.source_id, - [Message(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")], + [Message(role="user", contents=[f"{self.context_prompt}\n{line_separated_memories}"])], ) except Exception as e: # Log but don't fail - memory retrieval is non-critical diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index d07b5087fd..335d7ef394 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "agent-framework-foundry" -description = "Cloud Azure AI Foundry integration for Microsoft Agent Framework." +description = "Microsoft Foundry integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc6" +version = "1.0.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -23,8 +23,9 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", - "agent-framework-openai>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", + "agent-framework-openai>=1.0.0,<2", + "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.0.0,<3.0", ] diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 5691de70e1..a4e9afdd3e 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -3,7 +3,6 @@ from __future__ import annotations import inspect -import json import os import sys from functools import wraps @@ -360,7 +359,7 @@ async def test_web_search_tool_with_location() -> None: assert web_search_tool.user_location.city == "Seattle" assert web_search_tool.user_location.country == "US" _, run_options, _ = await client._prepare_request( - messages=[Message(role="user", text="What's the weather?")], + messages=[Message(role="user", contents=["What's the weather?"])], options={"tools": [web_search_tool], "tool_choice": "auto"}, ) @@ -388,7 +387,7 @@ async def test_code_interpreter_tool_variations() -> None: assert code_tool_with_files.container.file_ids == ["file1", "file2"] _, run_options, _ = await client._prepare_request( - messages=[Message(role="user", text="Process these files")], + messages=[Message(role="user", contents=["Process these files"])], options={"tools": [code_tool_with_files]}, ) @@ -429,7 +428,7 @@ async def test_chat_message_parsing_with_function_calls() -> None: ) function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully") messages = [ - Message(role="user", text="Call a function"), + Message(role="user", contents=["Call a function"]), Message(role="assistant", contents=[function_call]), Message(role="tool", contents=[function_result]), ] @@ -472,7 +471,7 @@ async def test_content_filter_exception() -> None: client.client.responses.create.side_effect = mock_error with pytest.raises(OpenAIContentFilterException) as exc_info: - await client.get_response(messages=[Message(role="user", text="Test message")]) + await client.get_response(messages=[Message(role="user", contents=["Test message"])]) assert "content error" in str(exc_info.value) @@ -496,7 +495,7 @@ async def test_response_format_parse_path() -> None: client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) response = await client.get_response( - messages=[Message(role="user", text="Test message")], + messages=[Message(role="user", contents=["Test message"])], options={"response_format": OutputStruct, "store": True}, ) assert response.response_id == "parsed_response_123" @@ -524,7 +523,7 @@ async def test_response_format_parse_path_with_conversation_id() -> None: client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) response = await client.get_response( - messages=[Message(role="user", text="Test message")], + messages=[Message(role="user", contents=["Test message"])], options={"response_format": OutputStruct, "store": True}, ) assert response.response_id == "parsed_response_123" @@ -532,6 +531,48 @@ async def test_response_format_parse_path_with_conversation_id() -> None: assert response.model == "test-model" +async def test_response_format_dict_parse_path() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + response_format = {"type": "object", "properties": {"answer": {"type": "string"}}} + + mock_response = MagicMock() + mock_response.id = "response_123" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.metadata = {} + mock_response.output_parsed = None + mock_response.output = [] + mock_response.usage = None + mock_response.finish_reason = None + mock_response.conversation = None + mock_response.status = "completed" + + mock_message_content = MagicMock() + mock_message_content.type = "output_text" + mock_message_content.text = '{"answer": "Parsed"}' + mock_message_content.annotations = [] + mock_message_content.logprobs = None + + mock_message_item = MagicMock() + mock_message_item.type = "message" + mock_message_item.content = [mock_message_content] + mock_response.output = [mock_message_item] + client.client.responses.create = AsyncMock(return_value=mock_response) + + response = await client.get_response( + messages=[Message(role="user", contents=["Test message"])], + options={"response_format": response_format}, + ) + + assert response.response_id == "response_123" + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["answer"] == "Parsed" + + async def test_bad_request_error_non_content_filter() -> None: mock_openai_client = _make_mock_openai_client() project_client = MagicMock() @@ -548,7 +589,7 @@ async def test_bad_request_error_non_content_filter() -> None: with pytest.raises(ChatClientException) as exc_info: await client.get_response( - messages=[Message(role="user", text="Test message")], + messages=[Message(role="user", contents=["Test message"])], options={"response_format": OutputStruct}, ) @@ -615,12 +656,12 @@ async def test_integration_options( client.function_invocation_configuration["max_iterations"] = 2 if option_name.startswith("tools") or option_name.startswith("tool_choice"): - messages = [Message(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", contents=["What is the weather in Seattle?"])] elif option_name.startswith("response_format"): - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) + messages = [Message(role="user", contents=["The weather in Seattle is sunny"])] + messages.append(Message(role="user", contents=["What is the weather in Seattle?"])) else: - messages = [Message(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", contents=["Say 'Hello World' briefly."])] options: dict[str, Any] = {option_name: option_value} if option_name.startswith("tool_choice"): @@ -642,10 +683,9 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - assert response.value is None - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value @pytest.mark.flaky @@ -660,7 +700,7 @@ async def test_integration_web_search() -> None: "messages": [ Message( role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + contents=["Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer."], ) ], "options": {"tool_choice": "auto", "tools": [web_search_tool]}, @@ -688,7 +728,7 @@ async def test_integration_tool_rich_content_image() -> None: client = FoundryChatClient(credential=AzureCliCredential()) client.function_invocation_configuration["max_iterations"] = 2 - messages = [Message(role="user", text="Call the get_test_image tool and describe what you see.")] + messages = [Message(role="user", contents=["Call the get_test_image tool and describe what you see."])] options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} response = await client.get_response(messages=messages, options=options, stream=True).get_final_response() diff --git a/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py similarity index 61% rename from python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py rename to python/packages/foundry/tests/foundry/test_foundry_embedding_client.py index 0ec6a8b811..e9e342d675 100644 --- a/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py @@ -10,10 +10,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import Content -from agent_framework_azure_ai import ( - AzureAIInferenceEmbeddingClient, - AzureAIInferenceEmbeddingOptions, - RawAzureAIInferenceEmbeddingClient, +from agent_framework_foundry import ( + FoundryEmbeddingClient, + FoundryEmbeddingOptions, + RawFoundryEmbeddingClient, ) @@ -57,10 +57,10 @@ def mock_image_client() -> AsyncMock: @pytest.fixture -def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> RawAzureAIInferenceEmbeddingClient[Any]: - """Create a RawAzureAIInferenceEmbeddingClient with mocked SDK clients.""" - return RawAzureAIInferenceEmbeddingClient( - model_id="test-model", +def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> RawFoundryEmbeddingClient[Any]: + """Create a RawFoundryEmbeddingClient with mocked SDK clients.""" + return RawFoundryEmbeddingClient( + model="test-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -69,10 +69,10 @@ def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> Raw @pytest.fixture -def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> AzureAIInferenceEmbeddingClient[Any]: - """Create an AzureAIInferenceEmbeddingClient with mocked SDK clients.""" - return AzureAIInferenceEmbeddingClient( - model_id="test-model", +def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> FoundryEmbeddingClient[Any]: + """Create a FoundryEmbeddingClient with mocked SDK clients.""" + return FoundryEmbeddingClient( + model="test-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -80,11 +80,11 @@ def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> AzureAI ) -class TestRawAzureAIInferenceEmbeddingClient: - """Tests for the raw Azure AI Inference embedding client.""" +class TestRawFoundryEmbeddingClient: + """Tests for the raw Foundry embedding client.""" async def test_text_embeddings( - self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock ) -> None: """Text inputs are dispatched to the text client.""" result = await raw_client.get_embeddings(["hello", "world"]) @@ -94,7 +94,7 @@ class TestRawAzureAIInferenceEmbeddingClient: assert call_kwargs.kwargs["model"] == "test-model" async def test_text_content_embeddings( - self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock ) -> None: """Content.from_text() inputs are dispatched to the text client.""" text_content = Content.from_text("hello") @@ -105,7 +105,7 @@ class TestRawAzureAIInferenceEmbeddingClient: assert call_kwargs.kwargs["input"] == ["hello"] async def test_image_content_embeddings( - self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_image_client: AsyncMock + self, raw_client: RawFoundryEmbeddingClient[Any], mock_image_client: AsyncMock ) -> None: """Image Content inputs are dispatched to the image client.""" image_content = Content.from_data(data=b"\x89PNG", media_type="image/png") @@ -119,7 +119,7 @@ class TestRawAzureAIInferenceEmbeddingClient: async def test_mixed_text_and_image( self, - raw_client: RawAzureAIInferenceEmbeddingClient[Any], + raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock, mock_image_client: AsyncMock, ) -> None: @@ -138,16 +138,16 @@ class TestRawAzureAIInferenceEmbeddingClient: image_call = mock_image_client.embed.call_args assert len(image_call.kwargs["input"]) == 1 - async def test_empty_input(self, raw_client: RawAzureAIInferenceEmbeddingClient[Any]) -> None: + async def test_empty_input(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None: """Empty input returns empty result.""" result = await raw_client.get_embeddings([]) assert len(result) == 0 async def test_options_passed_through( - self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock ) -> None: """Options are passed through to the SDK.""" - options: AzureAIInferenceEmbeddingOptions = { + options: FoundryEmbeddingOptions = { "dimensions": 512, "input_type": "document", "encoding_format": "float", @@ -160,23 +160,23 @@ class TestRawAzureAIInferenceEmbeddingClient: assert call_kwargs.kwargs["encoding_format"] == "float" async def test_model_override_in_options( - self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock ) -> None: - """model_id in options overrides the default.""" - options: AzureAIInferenceEmbeddingOptions = {"model_id": "custom-model"} + """model in options overrides the default.""" + options: FoundryEmbeddingOptions = {"model": "custom-model"} await raw_client.get_embeddings(["hello"], options=options) call_kwargs = mock_text_client.embed.call_args assert call_kwargs.kwargs["model"] == "custom-model" - async def test_unsupported_content_type_raises(self, raw_client: RawAzureAIInferenceEmbeddingClient[Any]) -> None: + async def test_unsupported_content_type_raises(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None: """Non-text, non-image Content raises ValueError.""" error_content = Content("error", message="fail") with pytest.raises(ValueError, match="Unsupported Content type"): await raw_client.get_embeddings([error_content]) async def test_usage_metadata( - self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock ) -> None: """Usage metadata is populated from the response.""" mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]], prompt_tokens=42) @@ -184,7 +184,7 @@ class TestRawAzureAIInferenceEmbeddingClient: assert result.usage is not None assert result.usage["input_token_count"] == 42 - def test_service_url(self, raw_client: RawAzureAIInferenceEmbeddingClient[Any]) -> None: + def test_service_url(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None: """service_url returns the configured endpoint.""" assert raw_client.service_url() == "https://test.inference.ai.azure.com" @@ -194,57 +194,57 @@ class TestRawAzureAIInferenceEmbeddingClient: patch.dict( os.environ, { - "AZURE_AI_INFERENCE_ENDPOINT": "https://env.inference.ai.azure.com", - "AZURE_AI_INFERENCE_API_KEY": "env-key", - "AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID": "env-model", + "FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com", + "FOUNDRY_MODELS_API_KEY": "env-key", + "FOUNDRY_EMBEDDING_MODEL": "env-model", }, ), - patch("agent_framework_azure_ai._embedding_client.EmbeddingsClient"), - patch("agent_framework_azure_ai._embedding_client.ImageEmbeddingsClient"), + patch("agent_framework_foundry._embedding_client.EmbeddingsClient"), + patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"), ): - client = RawAzureAIInferenceEmbeddingClient() - assert client.model_id == "env-model" - assert client.image_model_id == "env-model" # falls back to model_id + client = RawFoundryEmbeddingClient() + assert client.model == "env-model" + assert client.image_model == "env-model" # falls back to model - def test_image_model_id_from_env(self) -> None: - """image_model_id is loaded from its own environment variable.""" + def test_image_model_from_env(self) -> None: + """image_model is loaded from its own environment variable.""" with ( patch.dict( os.environ, { - "AZURE_AI_INFERENCE_ENDPOINT": "https://env.inference.ai.azure.com", - "AZURE_AI_INFERENCE_API_KEY": "env-key", - "AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID": "text-model", - "AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID": "image-model", + "FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com", + "FOUNDRY_MODELS_API_KEY": "env-key", + "FOUNDRY_EMBEDDING_MODEL": "text-model", + "FOUNDRY_IMAGE_EMBEDDING_MODEL": "image-model", }, ), - patch("agent_framework_azure_ai._embedding_client.EmbeddingsClient"), - patch("agent_framework_azure_ai._embedding_client.ImageEmbeddingsClient"), + patch("agent_framework_foundry._embedding_client.EmbeddingsClient"), + patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"), ): - client = RawAzureAIInferenceEmbeddingClient() - assert client.model_id == "text-model" - assert client.image_model_id == "image-model" + client = RawFoundryEmbeddingClient() + assert client.model == "text-model" + assert client.image_model == "image-model" - def test_image_model_id_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: - """image_model_id can be set explicitly.""" - client = RawAzureAIInferenceEmbeddingClient( - model_id="text-model", - image_model_id="image-model", + def test_image_model_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: + """image_model can be set explicitly.""" + client = RawFoundryEmbeddingClient( + model="text-model", + image_model="image-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, image_client=mock_image_client, ) - assert client.model_id == "text-model" - assert client.image_model_id == "image-model" + assert client.model == "text-model" + assert client.image_model == "image-model" - async def test_image_model_id_sent_to_image_client( + async def test_image_model_sent_to_image_client( self, mock_text_client: AsyncMock, mock_image_client: AsyncMock ) -> None: - """image_model_id is passed to the image client embed call.""" - client = RawAzureAIInferenceEmbeddingClient( - model_id="text-model", - image_model_id="image-model", + """image_model is passed to the image client embed call.""" + client = RawFoundryEmbeddingClient( + model="text-model", + image_model="image-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -256,12 +256,10 @@ class TestRawAzureAIInferenceEmbeddingClient: assert call_kwargs.kwargs["model"] == "image-model" -class TestAzureAIInferenceEmbeddingClient: - """Tests for the telemetry-enabled Azure AI Inference embedding client.""" +class TestFoundryEmbeddingClient: + """Tests for the telemetry-enabled Foundry embedding client.""" - async def test_text_embeddings( - self, client: AzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock - ) -> None: + async def test_text_embeddings(self, client: FoundryEmbeddingClient[Any], mock_text_client: AsyncMock) -> None: """Text embeddings work through the telemetry layer.""" result = await client.get_embeddings(["hello"]) assert len(result) == 1 @@ -269,12 +267,12 @@ class TestAzureAIInferenceEmbeddingClient: async def test_otel_provider_name_default(self) -> None: """Default OTEL provider name is azure.ai.inference.""" - assert AzureAIInferenceEmbeddingClient.OTEL_PROVIDER_NAME == "azure.ai.inference" + assert FoundryEmbeddingClient.OTEL_PROVIDER_NAME == "azure.ai.inference" async def test_otel_provider_name_override(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: """OTEL provider name can be overridden.""" - client = AzureAIInferenceEmbeddingClient( - model_id="test-model", + client = FoundryEmbeddingClient( + model="test-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -284,33 +282,33 @@ class TestAzureAIInferenceEmbeddingClient: assert client.otel_provider_name == "custom-provider" -_SKIP_REASON = "Azure AI Inference integration tests disabled" +_SKIP_REASON = "Foundry inference integration tests disabled" -def _integration_tests_enabled() -> bool: +def _foundry_integration_tests_enabled() -> bool: return bool( - os.environ.get("AZURE_AI_INFERENCE_ENDPOINT") - and os.environ.get("AZURE_AI_INFERENCE_API_KEY") - and os.environ.get("AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID") + os.environ.get("FOUNDRY_MODELS_ENDPOINT") + and os.environ.get("FOUNDRY_MODELS_API_KEY") + and os.environ.get("FOUNDRY_EMBEDDING_MODEL") ) -skip_if_azure_ai_inference_integration_tests_disabled = pytest.mark.skipif( - not _integration_tests_enabled(), +skip_if_foundry_inference_integration_tests_disabled = pytest.mark.skipif( + not _foundry_integration_tests_enabled(), reason=_SKIP_REASON, ) -class TestAzureAIInferenceEmbeddingIntegration: - """Integration tests requiring a live Azure AI Inference endpoint.""" +class TestFoundryEmbeddingIntegration: + """Integration tests requiring a live Foundry inference endpoint.""" @pytest.mark.flaky @pytest.mark.integration - @skip_if_azure_ai_inference_integration_tests_disabled + @skip_if_foundry_inference_integration_tests_disabled async def test_text_embedding_live(self) -> None: """Generate text embeddings against a live endpoint.""" - client = AzureAIInferenceEmbeddingClient() + client = FoundryEmbeddingClient() result = await client.get_embeddings(["Hello, world!"]) assert len(result) == 1 assert len(result[0].vector) > 0 - assert result[0].model_id is not None + assert result[0].model is not None diff --git a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py index 005eed29ce..2e25ba38d4 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py +++ b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py @@ -156,7 +156,7 @@ async def test_retrieves_static_memories_on_first_run(mock_project_client: Async scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") await provider.before_run( # type: ignore[arg-type] agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -191,7 +191,7 @@ async def test_contextual_memories_added_to_context(mock_project_client: AsyncMo scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") await provider.before_run( # type: ignore[arg-type] agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -218,7 +218,7 @@ async def test_empty_input_skips_contextual_search(mock_project_client: AsyncMoc scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=[""])], session_id="s1") await provider.before_run( # type: ignore[arg-type] agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -240,7 +240,7 @@ async def test_empty_search_results_no_messages(mock_project_client: AsyncMock) scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1") await provider.before_run( # type: ignore[arg-type] agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -265,7 +265,7 @@ async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMoc scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") # First call await provider.before_run( # type: ignore[arg-type] @@ -280,7 +280,7 @@ async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMoc mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2 # Second call - should only search contextual, not static - ctx2 = SessionContext(input_messages=[Message(role="user", text="World")], session_id="s1") + ctx2 = SessionContext(input_messages=[Message(role="user", contents=["World"])], session_id="s1") await provider.before_run( # type: ignore[arg-type] agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) ) @@ -296,7 +296,7 @@ async def test_handles_search_exception_gracefully(mock_project_client: AsyncMoc scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") # Should not raise exception await provider.before_run( # type: ignore[arg-type] @@ -321,8 +321,8 @@ async def test_stores_input_and_response(mock_project_client: AsyncMock) -> None scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["question"])], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["answer"])]) await provider.after_run( # type: ignore[arg-type] agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -350,12 +350,12 @@ async def test_only_stores_user_assistant_system(mock_project_client: AsyncMock) session = AgentSession(session_id="test-session") ctx = SessionContext( input_messages=[ - Message(role="user", text="hello"), - Message(role="tool", text="tool output"), + Message(role="user", contents=["hello"]), + Message(role="tool", contents=["tool output"]), ], session_id="s1", ) - ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])]) await provider.after_run( # type: ignore[arg-type] agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -377,8 +377,8 @@ async def test_skips_empty_messages(mock_project_client: AsyncMock) -> None: session = AgentSession(session_id="test-session") ctx = SessionContext( input_messages=[ - Message(role="user", text=""), - Message(role="user", text=" "), + Message(role="user", contents=[""]), + Message(role="user", contents=[" "]), ], session_id="s1", ) @@ -402,8 +402,8 @@ async def test_uses_configured_update_delay(mock_project_client: AsyncMock) -> N update_delay=60, ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])]) await provider.after_run( # type: ignore[arg-type] agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -427,8 +427,8 @@ async def test_uses_previous_update_id_for_incremental_updates(mock_project_clie scope="user_123", ) session = AgentSession(session_id="test-session") - ctx1 = SessionContext(input_messages=[Message(role="user", text="first")], session_id="s1") - ctx1._response = AgentResponse(messages=[Message(role="assistant", text="response1")]) + ctx1 = SessionContext(input_messages=[Message(role="user", contents=["first"])], session_id="s1") + ctx1._response = AgentResponse(messages=[Message(role="assistant", contents=["response1"])]) # First update await provider.after_run( # type: ignore[arg-type] @@ -437,8 +437,8 @@ async def test_uses_previous_update_id_for_incremental_updates(mock_project_clie assert session.state[provider.source_id]["previous_update_id"] == "update-1" # Second update should use previous_update_id - ctx2 = SessionContext(input_messages=[Message(role="user", text="second")], session_id="s1") - ctx2._response = AgentResponse(messages=[Message(role="assistant", text="response2")]) + ctx2 = SessionContext(input_messages=[Message(role="user", contents=["second"])], session_id="s1") + ctx2._response = AgentResponse(messages=[Message(role="assistant", contents=["response2"])]) await provider.after_run( # type: ignore[arg-type] agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) @@ -458,8 +458,8 @@ async def test_handles_update_exception_gracefully(mock_project_client: AsyncMoc scope="user_123", ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])]) # Should not raise exception await provider.after_run( # type: ignore[arg-type] diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index 5957b51565..91f14ed230 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -13,7 +13,7 @@ Integration with Azure AI Foundry Local for local model inference. ```python from agent_framework.foundry import FoundryLocalClient -client = FoundryLocalClient(model_id="your-local-model") +client = FoundryLocalClient(model="your-local-model") response = await client.get_response("Hello") ``` diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 5b0e15f2a2..f51a77c054 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -23,7 +23,7 @@ from agent_framework._settings import load_settings from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient from foundry_local import FoundryLocalManager -from foundry_local.models import DeviceType +from foundry_local.models import DeviceType, FoundryModelInfo from openai import AsyncOpenAI from pydantic import BaseModel @@ -60,7 +60,7 @@ class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModel Keys: # Inherited from ChatOptions (supported via OpenAI-compatible API): - model_id: The model identifier or alias (e.g., 'phi-4-mini'). + model: The model identifier or alias (e.g., 'phi-4-mini'). temperature: Sampling temperature (0-2). top_p: Nucleus sampling parameter. max_tokens: Maximum tokens to generate. @@ -104,11 +104,6 @@ class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModel """Not applicable for local inference.""" -FOUNDRY_LOCAL_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", -} -"""Maps ChatOptions keys to OpenAI API parameter names (for compatibility).""" - FoundryLocalChatOptionsT = TypeVar( "FoundryLocalChatOptionsT", bound=TypedDict, # type: ignore[valid-type] @@ -295,8 +290,8 @@ class FoundryLocalClient( # will take a long time as the model is loaded then. # Alternatively, you could call the `download_model` and `load_model` methods # on the `manager` property manually. - client.manager.download_model(alias_or_model_id="phi-4-mini", device=DeviceType.CPU) - client.manager.load_model(alias_or_model_id="phi-4-mini", device=DeviceType.CPU) + client.manager.download_model("phi-4-mini", device=DeviceType.CPU) + client.manager.load_model("phi-4-mini", device=DeviceType.CPU) # You can also use the CLI: `foundry model load phi-4-mini --device Auto` @@ -328,8 +323,8 @@ class FoundryLocalClient( model_setting: str = settings["model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout) - model_info = manager.get_model_info( - alias_or_model_id=model_setting, + model_info: FoundryModelInfo | None = manager.get_model_info( + model_setting, device=device, ) if model_info is None: @@ -340,8 +335,8 @@ class FoundryLocalClient( ) raise ValueError(message) if prepare_model: - manager.download_model(alias_or_model_id=model_info.id, device=device) - manager.load_model(alias_or_model_id=model_info.id, device=device) + manager.download_model(model_info.id, device=device) + manager.load_model(model_info.id, device=device) super().__init__( model=model_info.id, diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index f43d6f2fde..1ee093516a 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", - "agent-framework-openai>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", + "agent-framework-openai>=1.0.0,<2", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index 02b42f22a6..43b2918ca0 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -34,7 +34,7 @@ def test_foundry_local_settings_init_with_explicit_values() -> None: @pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL"]], indirect=True) def test_foundry_local_settings_missing_model(foundry_local_unit_test_env: dict[str, str]) -> None: - """Test FoundryLocalSettings when model_id is missing raises error.""" + """Test FoundryLocalSettings when model is missing raises error.""" with pytest.raises(SettingNotFoundError, match="Required setting 'model'"): load_settings( FoundryLocalSettings, @@ -157,15 +157,15 @@ def test_foundry_local_client_init_with_device(mock_foundry_local_manager: Magic FoundryLocalClient(model="test-model-id", device=DeviceType.CPU) mock_foundry_local_manager.get_model_info.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=DeviceType.CPU, ) mock_foundry_local_manager.download_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=DeviceType.CPU, ) mock_foundry_local_manager.load_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=DeviceType.CPU, ) @@ -207,10 +207,10 @@ def test_foundry_local_client_init_calls_download_and_load(mock_foundry_local_ma FoundryLocalClient(model="test-model-id") mock_foundry_local_manager.download_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=None, ) mock_foundry_local_manager.load_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=None, ) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 69f3bf20d4..4599cd3526 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -15,10 +15,12 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, BaseAgent, - BaseContextProvider, Content, + ContextProvider, + HistoryProvider, Message, ResponseStream, + SessionContext, normalize_messages, ) from agent_framework._settings import load_settings @@ -178,7 +180,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsT | None = None, @@ -352,13 +354,25 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): AgentException: If the request fails. """ if stream: + ctx_holder: dict[str, Any] = {} + + async def _after_run_hook(response: AgentResponse) -> None: + session_context = ctx_holder.get("session_context") + sess = ctx_holder.get("session") + if session_context is not None and sess is not None: + session_context._response = response + try: + await self._run_after_providers(session=sess, context=session_context) + except Exception: + logger.exception("Error running after_run providers in streaming result hook") def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: return AgentResponse.from_updates(updates) return ResponseStream( - self._stream_updates(messages=messages, session=session, options=options), + self._stream_updates(messages=messages, session=session, options=options, _ctx_holder=ctx_holder), finalizer=_finalize, + result_hooks=[_after_run_hook], ) return self._run_impl(messages=messages, session=session, options=options) @@ -377,11 +391,22 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): session = self.create_session() opts: dict[str, Any] = dict(options) if options else {} - timeout = opts.pop("timeout", None) or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS + timeout = opts.get("timeout") or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS - copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts) input_messages = normalize_messages(messages) - prompt = "\n".join([message.text for message in input_messages]) + + session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts) + + # NOTE: session is created after providers run so that future provider-contributed + # tools/config could be folded into runtime_options before session creation. + copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts) + + # Build the prompt from the full set of messages in the session context, + # so that any context/history provider-injected messages are included. + context_messages = session_context.get_messages(include_input=True) + prompt = "\n".join([message.text for message in context_messages]) + if session_context.instructions: + prompt = "\n".join(session_context.instructions) + "\n" + prompt message_options = cast(MessageOptions, {"prompt": prompt}) try: @@ -408,7 +433,10 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): ) response_id = message_id - return AgentResponse(messages=response_messages, response_id=response_id) + response = AgentResponse(messages=response_messages, response_id=response_id) + session_context._response = response # type: ignore[assignment] + await self._run_after_providers(session=session, context=session_context) + return response async def _stream_updates( self, @@ -416,6 +444,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, session: AgentSession | None = None, options: OptionsT | None = None, + _ctx_holder: dict[str, Any] | None = None, ) -> AsyncIterable[AgentResponseUpdate]: """Internal method to stream updates from GitHub Copilot. @@ -425,6 +454,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Keyword Args: session: The conversation session associated with the message(s). options: Runtime options (model, timeout, etc.). + _ctx_holder: Internal dict populated with session_context and session + so that the caller (via a ResponseStream result_hook) can run + after_run providers without duplicating the updates buffer. Yields: AgentResponseUpdate items. @@ -440,9 +472,23 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): opts: dict[str, Any] = dict(options) if options else {} - copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts) input_messages = normalize_messages(messages) - prompt = "\n".join([message.text for message in input_messages]) + + session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts) + + # NOTE: session is created after providers run so that future provider-contributed + # tools/config could be folded into runtime_options before session creation. + copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts) + + if _ctx_holder is not None: + _ctx_holder["session_context"] = session_context + _ctx_holder["session"] = session + + # Build the prompt from the full session context so provider-injected messages are included. + context_messages = session_context.get_messages(include_input=True) + prompt = "\n".join([message.text for message in context_messages]) + if session_context.instructions: + prompt = "\n".join(session_context.instructions) + "\n" + prompt message_options = cast(MessageOptions, {"prompt": prompt}) queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() @@ -513,6 +559,46 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): finally: unsubscribe() + async def _run_before_providers( + self, + *, + session: AgentSession, + input_messages: list[Message], + options: dict[str, Any], + ) -> SessionContext: + """Run before_run on all context providers and return the session context. + + Creates a SessionContext and invokes ``before_run`` on each provider in + forward order. ``HistoryProvider`` instances with + ``load_messages=False`` are skipped. + + Keyword Args: + session: The conversation session. + input_messages: The normalized input messages. + options: Runtime options dict. + + Returns: + The SessionContext with provider context populated. + """ + session_context = SessionContext( + session_id=session.session_id, + service_session_id=session.service_session_id, + input_messages=input_messages, + options=options, + ) + + for provider in self.context_providers: + if isinstance(provider, HistoryProvider) and not provider.load_messages: + continue + await provider.before_run( + agent=self, # type: ignore[arg-type] + session=session, + context=session_context, + state=session.state.setdefault(provider.source_id, {}), + ) + + return session_context + @staticmethod def _prepare_system_message( instructions: str | None, diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 5bcb2d3d8a..aecdaa02f9 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'", ] diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index d3aae50bba..e91a725765 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -17,6 +17,8 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, Content, + ContextProvider, + HistoryProvider, Message, ) from agent_framework.exceptions import AgentException @@ -1367,3 +1369,532 @@ class TestGitHubCopilotAgentPermissions: call_args = mock_client.create_session.call_args config = call_args[0][0] assert "on_permission_request" not in config + + +class SpyContextProvider(ContextProvider): + """A context provider that records whether its hooks are called.""" + + def __init__(self) -> None: + super().__init__(source_id="spy-provider") + self.before_run_called = False + self.after_run_called = False + self.before_run_context: Any = None + self.after_run_context: Any = None + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + self.before_run_called = True + self.before_run_context = context + context.instructions.append("Injected by spy provider") + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + self.after_run_called = True + self.after_run_context = context + + +class TestGitHubCopilotAgentContextProviders: + """Test cases for context provider integration.""" + + async def test_before_run_called_on_run( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that before_run is called on context providers during run().""" + mock_session.send_and_wait.return_value = assistant_message_event + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + await agent.run("Hello", session=session) + + assert spy.before_run_called + + async def test_after_run_called_on_run( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that after_run is called on context providers after run().""" + mock_session.send_and_wait.return_value = assistant_message_event + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + await agent.run("Hello", session=session) + + assert spy.after_run_called + + async def test_provider_instructions_included_in_prompt( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that instructions added by context providers are included in the prompt.""" + mock_session.send_and_wait.return_value = assistant_message_event + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + await agent.run("Hello", session=session) + + sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + assert "Injected by spy provider" in sent_prompt + + async def test_after_run_receives_response( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that after_run context contains the agent response.""" + mock_session.send_and_wait.return_value = assistant_message_event + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + await agent.run("Hello", session=session) + + assert spy.after_run_context is not None + assert spy.after_run_context.response is not None + + async def test_before_run_called_on_streaming( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test that before_run is called on context providers during streaming.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + async for _ in agent.run("Hello", stream=True, session=session): + pass + + assert spy.before_run_called + + async def test_after_run_called_on_streaming( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test that after_run is called on context providers after streaming.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + async for _ in agent.run("Hello", stream=True, session=session): + pass + + assert spy.after_run_called + + async def test_provider_instructions_included_in_streaming_prompt( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test that instructions from context providers are included in the streaming prompt.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + async for _ in agent.run("Hello", stream=True, session=session): + pass + + sent_prompt = mock_session.send.call_args[0][0]["prompt"] + assert "Injected by spy provider" in sent_prompt + + async def test_context_preserved_across_runs( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that provider state is preserved across multiple runs with the same session.""" + mock_session.send_and_wait.return_value = assistant_message_event + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + + await agent.run("Hello", session=session) + assert spy.before_run_called + + spy.before_run_called = False + await agent.run("Hello again", session=session) + assert spy.before_run_called + + async def test_context_messages_included_in_prompt( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that context messages added by providers via extend_messages are included in the prompt.""" + mock_session.send_and_wait.return_value = assistant_message_event + + class MessageInjectingProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="msg-injector") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + context.extend_messages(self, [Message(role="user", contents=[Content.from_text("History message")])]) + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + pass + + provider = MessageInjectingProvider() + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + await agent.run("Hello", session=session) + + sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + assert "History message" in sent_prompt + assert "Hello" in sent_prompt + + async def test_context_messages_included_in_streaming_prompt( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test that context messages added by providers are included in the streaming prompt.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + class MessageInjectingProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="msg-injector") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + context.extend_messages(self, [Message(role="user", contents=[Content.from_text("History message")])]) + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + pass + + provider = MessageInjectingProvider() + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + async for _ in agent.run("Hello", stream=True, session=session): + pass + + sent_prompt = mock_session.send.call_args[0][0]["prompt"] + assert "History message" in sent_prompt + assert "Hello" in sent_prompt + + async def test_after_run_not_called_on_error( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that after_run is NOT called when send_and_wait raises.""" + mock_session.send_and_wait.side_effect = Exception("Request failed") + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + with pytest.raises(AgentException): + await agent.run("Hello", session=session) + + assert spy.before_run_called + assert not spy.after_run_called + + async def test_after_run_not_called_on_streaming_error( + self, + mock_client: MagicMock, + mock_session: MagicMock, + session_error_event: SessionEvent, + ) -> None: + """Test that after_run is NOT called when streaming encounters an error.""" + events = [session_error_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + with pytest.raises(AgentException): + async for _ in agent.run("Hello", stream=True, session=session): + pass + + assert spy.before_run_called + assert not spy.after_run_called + + async def test_multiple_providers_ordering( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that before_run is called in forward order and after_run in reverse order.""" + mock_session.send_and_wait.return_value = assistant_message_event + call_order: list[str] = [] + + class OrderedProvider(ContextProvider): + def __init__(self, name: str) -> None: + super().__init__(source_id=name) + self.name = name + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + call_order.append(f"before:{self.name}") + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + call_order.append(f"after:{self.name}") + + providers = [OrderedProvider("A"), OrderedProvider("B"), OrderedProvider("C")] + agent = GitHubCopilotAgent(client=mock_client, context_providers=providers) + session = agent.create_session() + await agent.run("Hello", session=session) + + assert call_order == ["before:A", "before:B", "before:C", "after:C", "after:B", "after:A"] + + async def test_history_provider_skip_when_load_messages_false( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that HistoryProvider with load_messages=False is skipped in before_run.""" + mock_session.send_and_wait.return_value = assistant_message_event + + class StubHistoryProvider(HistoryProvider): + def __init__(self, *, load_messages: bool = True) -> None: + super().__init__(source_id="stub-history", load_messages=load_messages) + self.before_run_called = False + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + self.before_run_called = True + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + self.after_run_called = True + + async def get_messages(self, *, session_id: str, **kwargs: Any) -> list[Message]: + return [] + + async def save_messages(self, *, session_id: str, messages: list[Message], **kwargs: Any) -> None: + pass + + skipped_provider = StubHistoryProvider(load_messages=False) + active_provider = StubHistoryProvider(load_messages=True) + # Use unique source_ids + skipped_provider._source_id = "skipped-history" + active_provider._source_id = "active-history" + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[skipped_provider, active_provider]) + session = agent.create_session() + await agent.run("Hello", session=session) + + assert not skipped_provider.before_run_called + assert active_provider.before_run_called + # after_run should still be called even when load_messages=False + assert skipped_provider.after_run_called + assert active_provider.after_run_called + + async def test_streaming_after_run_response_has_updates( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test that streaming after_run context.response contains the aggregated updates.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + async for _ in agent.run("Hello", stream=True, session=session): + pass + + assert spy.after_run_context is not None + assert spy.after_run_context.response is not None + assert len(spy.after_run_context.response.messages) > 0 + assert spy.after_run_context.response.messages[0].text == "Hello" + + async def test_streaming_after_run_sets_empty_response_on_no_updates( + self, + mock_client: MagicMock, + mock_session: MagicMock, + session_idle_event: SessionEvent, + ) -> None: + """Test that streaming after_run sets an empty response when no updates are yielded.""" + events = [session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + spy = SpyContextProvider() + + agent = GitHubCopilotAgent(client=mock_client, context_providers=[spy]) + session = agent.create_session() + async for _ in agent.run("Hello", stream=True, session=session): + pass + + assert spy.after_run_called + assert spy.after_run_context.response is not None + assert len(spy.after_run_context.response.messages) == 0 + + async def test_timeout_preserved_in_session_context_options( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that timeout is preserved in session context options for providers.""" + mock_session.send_and_wait.return_value = assistant_message_event + observed_options: dict[str, Any] = {} + + class OptionsObserverProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="options-observer") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + observed_options.update(context.options) + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + pass + + provider = OptionsObserverProvider() + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + await agent.run("Hello", session=session, options={"timeout": 120}) + + assert observed_options.get("timeout") == 120 diff --git a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py index 266ae8a107..78f64ff565 100644 --- a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py +++ b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py @@ -71,7 +71,7 @@ class GAIATelemetryConfig: Note: For Azure Monitor integration, configure using environment variables - (OTEL_EXPORTER_OTLP_ENDPOINT, etc.) or use AzureAIClient.configure_azure_monitor() + (OTEL_EXPORTER_OTLP_ENDPOINT, etc.) or call ``configure_azure_monitor()`` before creating the GAIA instance. """ self.enable_tracing = enable_tracing diff --git a/python/packages/lab/gaia/samples/azure_ai_agent.py b/python/packages/lab/gaia/samples/azure_ai_agent.py index f83625b2c4..1be1144a95 100644 --- a/python/packages/lab/gaia/samples/azure_ai_agent.py +++ b/python/packages/lab/gaia/samples/azure_ai_agent.py @@ -6,8 +6,8 @@ This module provides a factory function to create an Azure AI agent configured for GAIA benchmark tasks. Required Environment Variables: - AZURE_AI_PROJECT_ENDPOINT: Azure AI project endpoint URL - AZURE_AI_MODEL_DEPLOYMENT_NAME: Name of the model deployment to use + FOUNDRY_PROJECT_ENDPOINT: Azure AI project endpoint URL + FOUNDRY_MODEL: Name of the model deployment to use Optional Environment Variables: BING_CONNECTION_ID: ID of the Bing connection for web search @@ -17,17 +17,18 @@ Authentication: Run `az login` before executing to authenticate. Example: - export AZURE_AI_PROJECT_ENDPOINT="https://your-project.azure.com" - export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" + export FOUNDRY_PROJECT_ENDPOINT="https://your-project.azure.com" + export FOUNDRY_MODEL="gpt-4o" export BING_CONNECTION_ID="connection-id" az login """ +import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from agent_framework import Agent -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential @@ -49,13 +50,17 @@ async def create_gaia_agent() -> AsyncIterator[Agent]: """ async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ).as_agent( name="GaiaAgent", instructions="Solve tasks to your best ability. Use Bing Search to find " "information and Code Interpreter to perform calculations and data analysis.", tools=[ - AzureAIAgentClient.get_web_search_tool(), - AzureAIAgentClient.get_code_interpreter_tool(), + FoundryChatClient.get_web_search_tool(), + FoundryChatClient.get_code_interpreter_tool(), ], ) as agent, ): diff --git a/python/packages/lab/gaia/samples/openai_agent.py b/python/packages/lab/gaia/samples/openai_agent.py index 227b12c03c..8075d8c298 100644 --- a/python/packages/lab/gaia/samples/openai_agent.py +++ b/python/packages/lab/gaia/samples/openai_agent.py @@ -7,7 +7,7 @@ configured for GAIA benchmark tasks using the OpenAI Responses API. Required Environment Variables: OPENAI_API_KEY: Your OpenAI API key - OPENAI_RESPONSES_MODEL: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) + OPENAI_CHAT_MODEL: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) Optional Environment Variables: OPENAI_BASE_URL: Custom API base URL if using a proxy or compatible service @@ -19,14 +19,14 @@ Authentication: Example: export OPENAI_API_KEY="sk-..." - export OPENAI_RESPONSES_MODEL="gpt-4o" + export OPENAI_CHAT_MODEL="gpt-4o" """ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient @asynccontextmanager @@ -47,15 +47,15 @@ async def create_gaia_agent() -> AsyncIterator[Agent]: result = await agent.run("What is the capital of France?") print(result.text) """ - client = OpenAIResponsesClient() + client = OpenAIChatClient() async with client.as_agent( name="GaiaAgent", instructions="Solve tasks to your best ability. Use Web Search to find " "information and Code Interpreter to perform calculations and data analysis.", tools=[ - OpenAIResponsesClient.get_web_search_tool(), - OpenAIResponsesClient.get_code_interpreter_tool(), + OpenAIChatClient.get_web_search_tool(), + OpenAIChatClient.get_code_interpreter_tool(), ], ) as agent: yield agent diff --git a/python/packages/lab/lightning/README.md b/python/packages/lab/lightning/README.md index e9fd3ec91d..75c585bdaa 100644 --- a/python/packages/lab/lightning/README.md +++ b/python/packages/lab/lightning/README.md @@ -51,7 +51,7 @@ async def math_agent(task: TaskType, llm: LLM) -> float: MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server, Agent( client=OpenAIChatClient( - model_id=llm.model, + model=llm.model, api_key="your-api-key", base_url=llm.endpoint, ), diff --git a/python/packages/lab/lightning/samples/train_math_agent.py b/python/packages/lab/lightning/samples/train_math_agent.py index 98e79484bc..a58e7886d1 100644 --- a/python/packages/lab/lightning/samples/train_math_agent.py +++ b/python/packages/lab/lightning/samples/train_math_agent.py @@ -169,7 +169,7 @@ async def math_agent(task: MathProblem, llm: LLM) -> float: MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server, Agent( client=OpenAIChatClient( - model_id=llm.model, # This is the model being trained + model=llm.model, # This is the model being trained api_key=os.getenv("OPENAI_API_KEY") or "dummy", # Can be dummy when connecting to training LLM base_url=llm.endpoint, # vLLM server endpoint provided by agent-lightning ), diff --git a/python/packages/lab/lightning/samples/train_tau2_agent.py b/python/packages/lab/lightning/samples/train_tau2_agent.py index e9514b6f77..ea07ddc7e8 100644 --- a/python/packages/lab/lightning/samples/train_tau2_agent.py +++ b/python/packages/lab/lightning/samples/train_tau2_agent.py @@ -106,14 +106,14 @@ class Tau2Agent(LitAgent): assistant_chat_client = OpenAIChatClient( base_url=llm.endpoint, # vLLM endpoint for the model being trained api_key=openai_api_key, - model_id=llm.model, # Model ID being trained + model=llm.model, # Model ID being trained ) # User simulator: uses a fixed, capable model for consistent simulation user_simulator_chat_client = OpenAIChatClient( base_url=openai_base_url, # External API endpoint api_key=openai_api_key, - model_id="gpt-4.1", # Fixed model for user simulator + model="gpt-4.1", # Fixed model for user simulator ) try: diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 07ae248d0b..d7a15aac4a 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework" authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,18 +22,18 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", ] [project.optional-dependencies] # GAIA benchmark module dependencies - gaia = [ - "pydantic>=2.0.0", - "opentelemetry-api>=1.39.0", - "opentelemetry-sdk>=1.39.0,<2", - "tqdm>=4.60.0", - "huggingface-hub>=0.20.0", - "orjson>=3.10.7,<4", +gaia = [ + "pydantic>=2.0.0", + "opentelemetry-api>=1.39.0", + "opentelemetry-sdk>=1.39.0,<2", + "tqdm>=4.60.0", + "huggingface-hub>=0.20.0", + "orjson>=3.10.7,<4", "pyarrow>=18.0.0", # For reading parquet files ] @@ -57,19 +57,19 @@ math = [ [dependency-groups] dev = [ - "uv==0.10.9", - "ruff==0.15.5", + "uv==0.11.3", + "ruff==0.15.8", "pytest==9.0.2", - "mypy==1.19.1", + "mypy==1.20.0", "pyright==1.1.408", #tasks "poethepoet==0.42.1", - "rich==13.7.1", - "tomli==2.4.0", + "rich>=13.7.1,<15.0.0", + "tomli==2.4.1", "tomli-w==1.2.0", # tau2 from source (not available on PyPI) "tau2@ git+https://github.com/sierra-research/tau2-bench@5ba9e3e56db57c5e4114bf7f901291f09b2c5619", - "prek==0.3.4", + "prek==0.3.8", ] [project.scripts] diff --git a/python/packages/lab/tau2/README.md b/python/packages/lab/tau2/README.md index 083fd05a9d..0e6b5ea735 100644 --- a/python/packages/lab/tau2/README.md +++ b/python/packages/lab/tau2/README.md @@ -67,12 +67,12 @@ async def run_single_task(): assistant_client = OpenAIChatClient( base_url="https://api.openai.com/v1", api_key="your-api-key", - model_id="gpt-4o" + model="gpt-4o" ) user_client = OpenAIChatClient( base_url="https://api.openai.com/v1", api_key="your-api-key", - model_id="gpt-4o-mini" + model="gpt-4o-mini" ) # Get a task and run it diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py index 6f3faf17c6..b78131db66 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py @@ -353,11 +353,11 @@ class TaskRunner: # Matches tau2's expected conversation start pattern logger.info(f"Starting workflow with hardcoded greeting: '{DEFAULT_FIRST_AGENT_MESSAGE}'") - first_message = Message(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE) + first_message = Message(role="assistant", contents=[DEFAULT_FIRST_AGENT_MESSAGE]) initial_greeting = AgentExecutorResponse( executor_id=ASSISTANT_AGENT_ID, agent_response=AgentResponse(messages=[first_message]), - full_conversation=[Message(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE)], + full_conversation=[Message(role="assistant", contents=[DEFAULT_FIRST_AGENT_MESSAGE])], ) # STEP 4: Execute the workflow and collect results diff --git a/python/packages/lab/tau2/samples/run_benchmark.py b/python/packages/lab/tau2/samples/run_benchmark.py index 5c2ed14d5e..5d11226bb1 100644 --- a/python/packages/lab/tau2/samples/run_benchmark.py +++ b/python/packages/lab/tau2/samples/run_benchmark.py @@ -96,14 +96,14 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st assistant_chat_client = OpenAIChatClient( base_url=openai_base_url, api_key=openai_api_key, - model_id=assistant_model, + model=assistant_model, ) # User simulator: simulates realistic customer behavior and requests user_simulator_chat_client = OpenAIChatClient( base_url=openai_base_url, api_key=openai_api_key, - model_id=user_model, + model=user_model, ) # STEP 4: Filter task set for debug mode @@ -133,8 +133,8 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st # Initialize result structure for this task result: dict[str, Any] = { "config": { - "assistant": assistant_chat_client.model_id, - "user": user_simulator_chat_client.model_id, + "assistant": assistant_chat_client.model, + "user": user_simulator_chat_client.model, }, "task": task, } @@ -183,8 +183,8 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st # Initialize result structure for this task result: dict[str, Any] = { "config": { - "assistant": assistant_chat_client.model_id, - "user": user_simulator_chat_client.model_id, + "assistant": assistant_chat_client.model, + "user": user_simulator_chat_client.model, }, "task": task, } diff --git a/python/packages/lab/tau2/tests/test_message_utils.py b/python/packages/lab/tau2/tests/test_message_utils.py index 8908140f94..601c716854 100644 --- a/python/packages/lab/tau2/tests/test_message_utils.py +++ b/python/packages/lab/tau2/tests/test_message_utils.py @@ -2,6 +2,13 @@ from unittest.mock import patch +import pytest + +try: + from litellm import completion as _litellm_completion # noqa: F401 +except Exception: + pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True) + from agent_framework._types import Content, Message from agent_framework_lab_tau2._message_utils import flip_messages, log_messages diff --git a/python/packages/lab/tau2/tests/test_sliding_window.py b/python/packages/lab/tau2/tests/test_sliding_window.py index e2960b19f6..8da46e8695 100644 --- a/python/packages/lab/tau2/tests/test_sliding_window.py +++ b/python/packages/lab/tau2/tests/test_sliding_window.py @@ -4,6 +4,13 @@ from unittest.mock import patch +import pytest + +try: + from litellm import completion as _litellm_completion # noqa: F401 +except Exception: + pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True) + from agent_framework import InMemoryHistoryProvider from agent_framework._types import Content, Message from agent_framework_lab_tau2._sliding_window import SlidingWindowHistoryProvider diff --git a/python/packages/lab/tau2/tests/test_tau2_utils.py b/python/packages/lab/tau2/tests/test_tau2_utils.py index 15957d5120..671ce0a695 100644 --- a/python/packages/lab/tau2/tests/test_tau2_utils.py +++ b/python/packages/lab/tau2/tests/test_tau2_utils.py @@ -2,6 +2,13 @@ """Tests for tau2 utils module.""" +import pytest + +try: + from litellm import completion as _litellm_completion # noqa: F401 +except Exception: + pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True) + from agent_framework import Content, FunctionTool, Message from agent_framework_lab_tau2._tau2_utils import ( convert_agent_framework_messages_to_tau2_messages, diff --git a/python/packages/mem0/agent_framework_mem0/_context_provider.py b/python/packages/mem0/agent_framework_mem0/_context_provider.py index 36b878e411..c6be708990 100644 --- a/python/packages/mem0/agent_framework_mem0/_context_provider.py +++ b/python/packages/mem0/agent_framework_mem0/_context_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Mem0 context provider using BaseContextProvider. +"""New-pattern Mem0 context provider using ContextProvider. This module provides ``Mem0ContextProvider``, built on the new -:class:`BaseContextProvider` hooks pattern. +:class:`ContextProvider` hooks pattern. """ from __future__ import annotations @@ -13,7 +13,7 @@ from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar from agent_framework import Message -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext +from agent_framework._sessions import AgentSession, ContextProvider, SessionContext from mem0 import AsyncMemory, AsyncMemoryClient if sys.version_info >= (3, 11): @@ -33,8 +33,8 @@ class _MemorySearchResponse_v1_1(TypedDict): _MemorySearchResponse_v2 = list[dict[str, Any]] -class Mem0ContextProvider(BaseContextProvider): - """Mem0 context provider using the new BaseContextProvider hooks pattern. +class Mem0ContextProvider(ContextProvider): + """Mem0 context provider using the new ContextProvider hooks pattern. Integrates Mem0 for persistent semantic memory, searching and storing memories via the Mem0 API. @@ -131,7 +131,7 @@ class Mem0ContextProvider(BaseContextProvider): if line_separated_memories: context.extend_messages( self.source_id, - [Message(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")], + [Message(role="user", contents=[f"{self.context_prompt}\n{line_separated_memories}"])], ) async def after_run( diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 46748866ae..7995afada6 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/mem0/tests/test_mem0_context_provider.py b/python/packages/mem0/tests/test_mem0_context_provider.py index 2a63d48c4e..bf40577878 100644 --- a/python/packages/mem0/tests/test_mem0_context_provider.py +++ b/python/packages/mem0/tests/test_mem0_context_provider.py @@ -94,7 +94,7 @@ class TestBeforeRun: ] provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") await provider.before_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -112,7 +112,7 @@ class TestBeforeRun: """Empty input messages → no search performed.""" provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=[""])], session_id="s1") await provider.before_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -126,7 +126,7 @@ class TestBeforeRun: mock_mem0_client.search.return_value = [] provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1") await provider.before_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -138,7 +138,7 @@ class TestBeforeRun: """Raises ValueError when no filters.""" provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1") with pytest.raises(ValueError, match="At least one of the filters"): await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] @@ -148,7 +148,7 @@ class TestBeforeRun: mock_mem0_client.search.return_value = {"results": [{"memory": "remembered fact"}]} provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1") await provider.before_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -164,8 +164,8 @@ class TestBeforeRun: session = AgentSession(session_id="test-session") ctx = SessionContext( input_messages=[ - Message(role="user", text="Hello"), - Message(role="user", text="World"), + Message(role="user", contents=["Hello"]), + Message(role="user", contents=["World"]), ], session_id="s1", ) @@ -182,7 +182,7 @@ class TestBeforeRun: mock_oss_mem0_client.search.return_value = [{"memory": "User likes Python"}] provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") await provider.before_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -200,7 +200,7 @@ class TestBeforeRun: source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1", application_id="app1" ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") await provider.before_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -216,7 +216,7 @@ class TestBeforeRun: mock_mem0_client.search.return_value = [] provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1") await provider.before_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -238,8 +238,8 @@ class TestAfterRun: """Stores input+response messages to mem0 via client.add.""" provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["question"])], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["answer"])]) await provider.after_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -260,12 +260,12 @@ class TestAfterRun: session = AgentSession(session_id="test-session") ctx = SessionContext( input_messages=[ - Message(role="user", text="hello"), - Message(role="tool", text="tool output"), + Message(role="user", contents=["hello"]), + Message(role="tool", contents=["tool output"]), ], session_id="s1", ) - ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])]) await provider.after_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -282,8 +282,8 @@ class TestAfterRun: session = AgentSession(session_id="test-session") ctx = SessionContext( input_messages=[ - Message(role="user", text=""), - Message(role="user", text=" "), + Message(role="user", contents=[""]), + Message(role="user", contents=[" "]), ], session_id="s1", ) @@ -299,8 +299,8 @@ class TestAfterRun: """run_id is not passed to mem0 add, so memories are not scoped to sessions.""" provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="my-session") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="my-session") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])]) await provider.after_run( agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) @@ -312,8 +312,8 @@ class TestAfterRun: """Raises ValueError when no filters.""" provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])]) with pytest.raises(ValueError, match="At least one of the filters"): await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] @@ -324,7 +324,7 @@ class TestAfterRun: source_id="mem0", mem0_client=mock_mem0_client, user_id="u1", application_id="app1" ) session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") ctx._response = AgentResponse(messages=[]) await provider.after_run( diff --git a/python/packages/ollama/AGENTS.md b/python/packages/ollama/AGENTS.md index 3a4aa0f928..be0c7af951 100644 --- a/python/packages/ollama/AGENTS.md +++ b/python/packages/ollama/AGENTS.md @@ -13,7 +13,7 @@ Integration with Ollama for local LLM inference. ```python from agent_framework.ollama import OllamaChatClient -client = OllamaChatClient(model_id="llama3.2") +client = OllamaChatClient(model="llama3.2") response = await client.get_response("Hello") ``` diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 0c7f232797..ee517fe53f 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -77,7 +77,7 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to Keys: # Inherited from ChatOptions (mapped to Ollama options): - model_id: The model name, translates to ``model`` in Ollama API. + model: The model name, translates to ``model`` in Ollama API. temperature: Sampling temperature, translates to ``options.temperature``. top_p: Nucleus sampling, translates to ``options.top_p``. max_tokens: Maximum tokens to generate, translates to ``options.num_predict``. @@ -229,7 +229,6 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to OLLAMA_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", "response_format": "format", } """Maps ChatOptions keys to Ollama API parameter names.""" @@ -278,7 +277,7 @@ class OllamaSettings(TypedDict, total=False): """Ollama settings.""" host: str | None - model_id: str | None + model: str | None logger = logging.getLogger("agent_framework.ollama") @@ -299,7 +298,7 @@ class OllamaChatClient( *, host: str | None = None, client: AsyncClient | None = None, - model_id: str | None = None, + model: str | None = None, additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, @@ -312,7 +311,7 @@ class OllamaChatClient( host: Ollama server URL, if none `http://localhost:11434` is used. Can be set via the OLLAMA_HOST env variable. client: An optional Ollama Client instance. If not provided, a new instance will be created. - model_id: The Ollama chat model ID to use. Can be set via the OLLAMA_MODEL_ID env variable. + model: The Ollama chat model to use. Can be set via the OLLAMA_MODEL env variable. additional_properties: Additional properties stored on the client instance. middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. @@ -322,14 +321,14 @@ class OllamaChatClient( ollama_settings = load_settings( OllamaSettings, env_prefix="OLLAMA_", - required_fields=["model_id"], + required_fields=["model"], host=host, - model_id=model_id, + model=model, env_file_encoding=env_file_encoding, env_file_path=env_file_path, ) - self.model_id = ollama_settings["model_id"] # type: ignore[assignment, reportTypedDictNotRequiredAccess] + self.model = ollama_settings["model"] # type: ignore[assignment, reportTypedDictNotRequiredAccess] # we can just pass in None for the host, the default is set by the Ollama package. self.client = client or AsyncClient(host=ollama_settings.get("host")) # Save Host URL for serialization with to_dict() @@ -383,7 +382,10 @@ class OllamaChatClient( except Exception as ex: raise ChatClientException(f"Ollama chat request failed : {ex}", ex) from ex - return self._parse_response_from_ollama(response) + return self._parse_response_from_ollama( + response, + response_format=validated_options.get("response_format"), + ) return _get_response() @@ -411,7 +413,7 @@ class OllamaChatClient( translated_key = OLLAMA_MODEL_OPTION_TRANSLATIONS.get(key, key) model_options[translated_key] = value else: - # Apply top-level translations (e.g., model_id -> model) + # Apply top-level translations (e.g., response_format -> format) translated_key = OLLAMA_OPTION_TRANSLATIONS.get(key, key) run_options[translated_key] = value @@ -425,11 +427,11 @@ class OllamaChatClient( if "messages" not in run_options: raise ChatClientInvalidRequestException("Messages are required for chat completions") - # model id + # model if not run_options.get("model"): - if not self.model_id: - raise ValueError("model_id must be a non-empty string") - run_options["model"] = self.model_id + if not self.model: + raise ValueError("model must be a non-empty string") + run_options["model"] = self.model # tools tools = options.get("tools") @@ -533,21 +535,27 @@ class OllamaChatClient( return ChatResponseUpdate( contents=contents, role="assistant", - model_id=response.model, + model=response.model, created_at=response.created_at, ) - def _parse_response_from_ollama(self, response: OllamaChatResponse) -> ChatResponse: + def _parse_response_from_ollama( + self, + response: OllamaChatResponse, + *, + response_format: Any | None = None, + ) -> ChatResponse: contents = self._parse_contents_from_ollama(response) return ChatResponse( messages=[Message(role="assistant", contents=contents)], - model_id=response.model, + model=response.model, created_at=response.created_at, usage_details=UsageDetails( input_token_count=response.prompt_eval_count, output_token_count=response.eval_count, ), + response_format=response_format, ) def _parse_tool_calls_from_ollama(self, tool_calls: Sequence[OllamaMessage.ToolCall]) -> list[Content]: diff --git a/python/packages/ollama/agent_framework_ollama/_embedding_client.py b/python/packages/ollama/agent_framework_ollama/_embedding_client.py index 8e0508c708..e921e6172a 100644 --- a/python/packages/ollama/agent_framework_ollama/_embedding_client.py +++ b/python/packages/ollama/agent_framework_ollama/_embedding_client.py @@ -38,7 +38,7 @@ class OllamaEmbeddingOptions(EmbeddingGenerationOptions, total=False): from agent_framework_ollama import OllamaEmbeddingOptions options: OllamaEmbeddingOptions = { - "model_id": "nomic-embed-text", + "model": "nomic-embed-text", "dimensions": 768, "truncate": True, } @@ -67,7 +67,7 @@ class OllamaEmbeddingSettings(TypedDict, total=False): """Ollama embedding settings.""" host: str | None - embedding_model_id: str | None + embedding_model: str | None class RawOllamaEmbeddingClient( @@ -77,8 +77,8 @@ class RawOllamaEmbeddingClient( """Raw Ollama embedding client without telemetry. Keyword Args: - model_id: The Ollama embedding model ID (e.g. "nomic-embed-text"). - Can also be set via environment variable OLLAMA_EMBEDDING_MODEL_ID. + model: The Ollama embedding model (e.g. "nomic-embed-text"). + Can also be set via environment variable OLLAMA_EMBEDDING_MODEL. host: Ollama server URL. Defaults to http://localhost:11434. Can also be set via environment variable OLLAMA_HOST. client: Optional pre-configured Ollama AsyncClient. @@ -89,7 +89,7 @@ class RawOllamaEmbeddingClient( def __init__( self, *, - model_id: str | None = None, + model: str | None = None, host: str | None = None, client: AsyncClient | None = None, additional_properties: dict[str, Any] | None = None, @@ -100,14 +100,14 @@ class RawOllamaEmbeddingClient( ollama_settings = load_settings( OllamaEmbeddingSettings, env_prefix="OLLAMA_", - required_fields=["embedding_model_id"], + required_fields=["embedding_model"], host=host, - embedding_model_id=model_id, + embedding_model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - self.model_id = ollama_settings["embedding_model_id"] # type: ignore[assignment,reportTypedDictNotRequiredAccess] + self.model = ollama_settings["embedding_model"] # type: ignore[assignment,reportTypedDictNotRequiredAccess] self.client = client or AsyncClient(host=ollama_settings.get("host")) self.host = str(self.client._client.base_url) # type: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType] super().__init__(additional_properties=additional_properties) @@ -132,15 +132,15 @@ class RawOllamaEmbeddingClient( Generated embeddings with usage metadata. Raises: - ValueError: If model_id is not provided or values is empty. + ValueError: If model is not provided or values is empty. """ if not values: return GeneratedEmbeddings([], options=options) opts: dict[str, Any] = options or {} # type: ignore - model = opts.get("model_id") or self.model_id + model = opts.get("model") or self.model if not model: - raise ValueError("model_id is required") + raise ValueError("model is required") kwargs: dict[str, Any] = {"model": model, "input": list(values)} if (truncate := opts.get("truncate")) is not None: @@ -156,7 +156,7 @@ class RawOllamaEmbeddingClient( Embedding( vector=list(emb), dimensions=len(emb), - model_id=response.get("model") or model, # type: ignore[assignment] + model=response.get("model") or model, # type: ignore[assignment] ) for emb in response.get("embeddings", []) ] @@ -177,8 +177,8 @@ class OllamaEmbeddingClient( """Ollama embedding client with telemetry support. Keyword Args: - model_id: The Ollama embedding model ID (e.g. "nomic-embed-text"). - Can also be set via environment variable OLLAMA_EMBEDDING_MODEL_ID. + model: The Ollama embedding model (e.g. "nomic-embed-text"). + Can also be set via environment variable OLLAMA_EMBEDDING_MODEL. host: Ollama server URL. Defaults to http://localhost:11434. Can also be set via environment variable OLLAMA_HOST. client: Optional pre-configured Ollama AsyncClient. @@ -191,12 +191,12 @@ class OllamaEmbeddingClient( from agent_framework_ollama import OllamaEmbeddingClient # Using environment variables - # Set OLLAMA_EMBEDDING_MODEL_ID=nomic-embed-text + # Set OLLAMA_EMBEDDING_MODEL=nomic-embed-text client = OllamaEmbeddingClient() # Or passing parameters directly client = OllamaEmbeddingClient( - model_id="nomic-embed-text", + model="nomic-embed-text", host="http://localhost:11434", ) @@ -210,7 +210,7 @@ class OllamaEmbeddingClient( def __init__( self, *, - model_id: str | None = None, + model: str | None = None, host: str | None = None, client: AsyncClient | None = None, otel_provider_name: str | None = None, @@ -220,7 +220,7 @@ class OllamaEmbeddingClient( ) -> None: """Initialize an Ollama embedding client.""" super().__init__( - model_id=model_id, + model=model, host=host, client=client, additional_properties=additional_properties, diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index b10ea2706f..dd18f2d14b 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py b/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py index c585f9c481..63dffeb7c1 100644 --- a/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py +++ b/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py @@ -13,11 +13,11 @@ from agent_framework_ollama import OllamaEmbeddingClient, OllamaEmbeddingOptions def test_ollama_embedding_construction(monkeypatch: pytest.MonkeyPatch) -> None: """Test construction with explicit parameters.""" - monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL_ID", "nomic-embed-text") + monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text") with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: mock_client_cls.return_value = MagicMock() client = OllamaEmbeddingClient() - assert client.model_id == "nomic-embed-text" + assert client.model == "nomic-embed-text" def test_ollama_embedding_construction_with_params() -> None: @@ -25,16 +25,16 @@ def test_ollama_embedding_construction_with_params() -> None: with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: mock_client_cls.return_value = MagicMock() client = OllamaEmbeddingClient( - model_id="nomic-embed-text", + model="nomic-embed-text", host="http://localhost:11434", ) - assert client.model_id == "nomic-embed-text" + assert client.model == "nomic-embed-text" def test_ollama_embedding_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - """Test that missing model_id raises an error.""" - monkeypatch.delenv("OLLAMA_EMBEDDING_MODEL_ID", raising=False) - monkeypatch.delenv("OLLAMA_MODEL_ID", raising=False) + """Test that missing model raises an error.""" + monkeypatch.delenv("OLLAMA_EMBEDDING_MODEL", raising=False) + monkeypatch.delenv("OLLAMA_MODEL", raising=False) from agent_framework.exceptions import SettingNotFoundError with pytest.raises(SettingNotFoundError): @@ -54,14 +54,14 @@ async def test_ollama_embedding_get_embeddings() -> None: mock_client.embed = AsyncMock(return_value=mock_response) mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") + client = OllamaEmbeddingClient(model="nomic-embed-text") result = await client.get_embeddings(["hello", "world"]) assert isinstance(result, GeneratedEmbeddings) assert len(result) == 2 assert result[0].vector == [0.1, 0.2, 0.3] assert result[1].vector == [0.4, 0.5, 0.6] - assert result[0].model_id == "nomic-embed-text" + assert result[0].model == "nomic-embed-text" assert result.usage == {"input_token_count": 10} mock_client.embed.assert_called_once_with( @@ -76,7 +76,7 @@ async def test_ollama_embedding_get_embeddings_empty_input() -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") + client = OllamaEmbeddingClient(model="nomic-embed-text") result = await client.get_embeddings([]) assert isinstance(result, GeneratedEmbeddings) @@ -96,7 +96,7 @@ async def test_ollama_embedding_get_embeddings_with_options() -> None: mock_client.embed = AsyncMock(return_value=mock_response) mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") + client = OllamaEmbeddingClient(model="nomic-embed-text") options: OllamaEmbeddingOptions = { "truncate": True, "dimensions": 512, @@ -113,22 +113,22 @@ async def test_ollama_embedding_get_embeddings_with_options() -> None: async def test_ollama_embedding_get_embeddings_no_model_raises() -> None: - """Test that missing model_id at call time raises ValueError.""" + """Test that missing model at call time raises ValueError.""" with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: mock_client = MagicMock() mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") - client.model_id = None # type: ignore[assignment] + client = OllamaEmbeddingClient(model="nomic-embed-text") + client.model = None # type: ignore[assignment] - with pytest.raises(ValueError, match="model_id is required"): + with pytest.raises(ValueError, match="model is required"): await client.get_embeddings(["hello"]) # region: Integration Tests skip_if_ollama_embedding_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OLLAMA_EMBEDDING_MODEL_ID", "") in ("", "test-model"), + os.getenv("OLLAMA_EMBEDDING_MODEL", "") in ("", "test-model"), reason="No real Ollama embedding model provided; skipping integration tests.", ) diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 490bbc0a15..5e0daea0f5 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -26,7 +26,7 @@ from agent_framework_ollama import OllamaChatClient # region Service Setup skip_if_azure_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OLLAMA_MODEL_ID", "") in ("", "test-model"), + os.getenv("OLLAMA_MODEL", "") in ("", "test-model"), reason="No real Ollama chat model provided; skipping integration tests.", ) @@ -55,7 +55,7 @@ def ollama_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # if override_env_param_dict is None: override_env_param_dict = {} - env_vars = {"OLLAMA_HOST": "http://localhost:12345", "OLLAMA_MODEL_ID": "test"} + env_vars = {"OLLAMA_HOST": "http://localhost:12345", "OLLAMA_MODEL": "test"} env_vars.update(override_env_param_dict) # type: ignore @@ -156,7 +156,7 @@ def test_init(ollama_unit_test_env: dict[str, str]) -> None: assert ollama_chat_client.client is not None assert isinstance(ollama_chat_client.client, AsyncClient) - assert ollama_chat_client.model_id == ollama_unit_test_env["OLLAMA_MODEL_ID"] + assert ollama_chat_client.model == ollama_unit_test_env["OLLAMA_MODEL"] assert isinstance(ollama_chat_client, BaseChatClient) @@ -165,27 +165,27 @@ def test_init_client(ollama_unit_test_env: dict[str, str]) -> None: test_client = MagicMock(spec=AsyncClient) # Mock underlying HTTP client's base_url test_client._client = MagicMock() - test_client._client.base_url = ollama_unit_test_env["OLLAMA_MODEL_ID"] + test_client._client.base_url = ollama_unit_test_env["OLLAMA_MODEL"] ollama_chat_client = OllamaChatClient(client=test_client) assert ollama_chat_client.client is test_client - assert ollama_chat_client.model_id == ollama_unit_test_env["OLLAMA_MODEL_ID"] + assert ollama_chat_client.model == ollama_unit_test_env["OLLAMA_MODEL"] assert isinstance(ollama_chat_client, BaseChatClient) -@pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL_ID"]], indirect=True) +@pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL"]], indirect=True) def test_with_invalid_settings(ollama_unit_test_env: dict[str, str]) -> None: - with pytest.raises(SettingNotFoundError, match="Required setting 'model_id'"): + with pytest.raises(SettingNotFoundError, match="Required setting 'model'"): OllamaChatClient( host="http://localhost:12345", - model_id=None, + model=None, ) def test_serialize(ollama_unit_test_env: dict[str, str]) -> None: settings = { "host": ollama_unit_test_env["OLLAMA_HOST"], - "model_id": ollama_unit_test_env["OLLAMA_MODEL_ID"], + "model": ollama_unit_test_env["OLLAMA_MODEL"], } ollama_chat_client = OllamaChatClient.from_dict(settings) @@ -193,7 +193,7 @@ def test_serialize(ollama_unit_test_env: dict[str, str]) -> None: assert isinstance(serialized, dict) assert serialized["host"] == ollama_unit_test_env["OLLAMA_HOST"] - assert serialized["model_id"] == ollama_unit_test_env["OLLAMA_MODEL_ID"] + assert serialized["model"] == ollama_unit_test_env["OLLAMA_MODEL"] def test_chat_middleware(ollama_unit_test_env: dict[str, str]) -> None: @@ -225,7 +225,7 @@ def test_additional_properties(ollama_unit_test_env: dict[str, str]) -> None: async def test_empty_messages() -> None: ollama_chat_client = OllamaChatClient( host="http://localhost:12345", - model_id="test-model", + model="test-model", ) with pytest.raises(ChatClientInvalidRequestException): await ollama_chat_client.get_response(messages=[]) @@ -239,8 +239,8 @@ async def test_cmc( mock_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_chat_completion_response - chat_history.append(Message(text="hello world", role="system")) - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="system")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history) @@ -248,6 +248,33 @@ async def test_cmc( assert result.text == "test" +@patch.object(AsyncClient, "chat", new_callable=AsyncMock) +async def test_cmc_response_format_dict( + mock_chat: AsyncMock, + ollama_unit_test_env: dict[str, str], + chat_history: list[Message], +) -> None: + mock_chat.return_value = OllamaChatResponse( + message=OllamaMessage(content='{"answer": "test"}', role="assistant"), + model="test", + eval_count=1, + prompt_eval_count=1, + created_at="2024-01-01T00:00:00Z", + ) + chat_history.append(Message(contents=["hello world"], role="system")) + chat_history.append(Message(contents=["hello world"], role="user")) + + ollama_client = OllamaChatClient() + result = await ollama_client.get_response( + messages=chat_history, + options={"response_format": {"type": "object", "properties": {"answer": {"type": "string"}}}}, + ) + + assert result.value is not None + assert isinstance(result.value, dict) + assert result.value["answer"] == "test" + + @patch.object(AsyncClient, "chat", new_callable=AsyncMock) async def test_cmc_reasoning( mock_chat: AsyncMock, @@ -256,7 +283,7 @@ async def test_cmc_reasoning( mock_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_chat_completion_response_reasoning - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history) @@ -273,7 +300,7 @@ async def test_cmc_chat_failure( ) -> None: # Simulate a failure in the Ollama client mock_chat.side_effect = Exception("Connection error") - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() @@ -292,8 +319,8 @@ async def test_cmc_streaming( mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_streaming_chat_completion_response - chat_history.append(Message(text="hello world", role="system")) - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="system")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() result = ollama_client.get_response(messages=chat_history, stream=True) @@ -310,7 +337,7 @@ async def test_cmc_streaming_reasoning( mock_streaming_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_streaming_chat_completion_response_reasoning - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() result = ollama_client.get_response(messages=chat_history, stream=True) @@ -328,7 +355,7 @@ async def test_cmc_streaming_chat_failure( ) -> None: # Simulate a failure in the Ollama client for streaming mock_chat.side_effect = Exception("Streaming connection error") - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() @@ -353,7 +380,7 @@ async def test_cmc_streaming_with_tool_call( mock_streaming_chat_completion_response, ] - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() result = ollama_client.get_response(messages=chat_history, stream=True, options={"tools": [hello_world]}) @@ -384,7 +411,7 @@ async def test_cmc_with_dict_tool_passthrough( ) -> None: """Test that dict-based tools are passed through to Ollama.""" mock_chat.return_value = mock_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) + chat_history.append(Message(contents=["hello world"], role="user")) ollama_client = OllamaChatClient() await ollama_client.get_response( @@ -473,7 +500,7 @@ async def test_cmc_with_invalid_content_type( async def test_cmc_integration_with_tool_call( chat_history: list[Message], ) -> None: - chat_history.append(Message(text="Call the hello world function and repeat what it says", role="user")) + chat_history.append(Message(contents=["Call the hello world function and repeat what it says"], role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history, options={"tools": [hello_world]}) @@ -490,7 +517,7 @@ async def test_cmc_integration_with_tool_call( async def test_cmc_integration_with_chat_completion( chat_history: list[Message], ) -> None: - chat_history.append(Message(text="Say Hello World", role="user")) + chat_history.append(Message(contents=["Say Hello World"], role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history) @@ -504,7 +531,7 @@ async def test_cmc_integration_with_chat_completion( async def test_cmc_streaming_integration_with_tool_call( chat_history: list[Message], ) -> None: - chat_history.append(Message(text="Call the hello world function and repeat what it says", role="user")) + chat_history.append(Message(contents=["Call the hello world function and repeat what it says"], role="user")) ollama_client = OllamaChatClient() result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response( @@ -531,7 +558,7 @@ async def test_cmc_streaming_integration_with_tool_call( async def test_cmc_streaming_integration_with_chat_completion( chat_history: list[Message], ) -> None: - chat_history.append(Message(text="Say Hello World", role="user")) + chat_history.append(Message(contents=["Say Hello World"], role="user")) ollama_client = OllamaChatClient() result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response(messages=chat_history, stream=True) diff --git a/python/packages/openai/AGENTS.md b/python/packages/openai/AGENTS.md index 48c3a306bd..752919fa28 100644 --- a/python/packages/openai/AGENTS.md +++ b/python/packages/openai/AGENTS.md @@ -11,9 +11,7 @@ agent_framework_openai/ ├── _chat_completion_client.py # OpenAIChatCompletionClient (Chat Completions API) + RawOpenAIChatCompletionClient ├── _embedding_client.py # OpenAIEmbeddingClient ├── _exceptions.py # OpenAI-specific exceptions -├── _shared.py # OpenAIBase, OpenAIConfigMixin, OpenAISettings -├── _assistants_client.py # OpenAIAssistantsClient (DEPRECATED) -└── _assistant_provider.py # OpenAIAssistantProvider (DEPRECATED) +└── _shared.py # OpenAISettings and shared config helpers ``` ## Key Classes @@ -23,7 +21,6 @@ agent_framework_openai/ | `OpenAIChatClient` | Responses API | Primary | | `OpenAIChatCompletionClient` | Chat Completions API | Primary | | `OpenAIEmbeddingClient` | Embeddings API | Primary | -| `OpenAIAssistantsClient` | Assistants API | Deprecated | All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`). @@ -35,4 +32,3 @@ explicit Azure inputs (`credential`, `azure_endpoint`, `api_version`) → OpenAI - `agent-framework-core` — core abstractions - `openai` — OpenAI Python SDK -- `packaging` — version checking diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md index e04a1f947a..d517c4e1d1 100644 --- a/python/packages/openai/README.md +++ b/python/packages/openai/README.md @@ -11,7 +11,7 @@ This package provides: ## Installation ```bash -pip install agent-framework-openai --pre +pip install agent-framework-openai ``` ## Which chat client should I use? @@ -22,7 +22,7 @@ Use `OpenAIChatClient` for new work unless you specifically need the Chat Comple - `OpenAIChatCompletionClient` uses the Chat Completions API and is mainly for compatibility with existing Chat Completions-based integrations. -The deprecated `OpenAIResponsesClient` alias points to `OpenAIChatClient`. +The previous deprecated Responses alias has been removed. Use `OpenAIChatClient` directly. ## Environment variables @@ -36,16 +36,20 @@ These variables are used when the client is configured for OpenAI: | `OPENAI_ORG_ID` | OpenAI organization ID | | `OPENAI_BASE_URL` | Custom OpenAI-compatible base URL | | `OPENAI_MODEL` | Generic fallback model | -| `OPENAI_RESPONSES_MODEL` | Preferred model for `OpenAIChatClient` | -| `OPENAI_CHAT_MODEL` | Preferred model for `OpenAIChatCompletionClient` | +| `OPENAI_CHAT_MODEL` | Preferred model for `OpenAIChatClient` | +| `OPENAI_CHAT_COMPLETION_MODEL` | Preferred model for `OpenAIChatCompletionClient` | | `OPENAI_EMBEDDING_MODEL` | Preferred model for `OpenAIEmbeddingClient` | Model lookup order: -- `OpenAIChatClient`: `OPENAI_RESPONSES_MODEL` -> `OPENAI_MODEL` -- `OpenAIChatCompletionClient`: `OPENAI_CHAT_MODEL` -> `OPENAI_MODEL` +- `OpenAIChatClient`: `OPENAI_CHAT_MODEL` -> `OPENAI_MODEL` +- `OpenAIChatCompletionClient`: `OPENAI_CHAT_COMPLETION_MODEL` -> `OPENAI_MODEL` - `OpenAIEmbeddingClient`: `OPENAI_EMBEDDING_MODEL` -> `OPENAI_MODEL` +These model variables are only consulted when you do not pass `model=` directly. In other words, +`OpenAIChatClient(model="...")` ignores `OPENAI_CHAT_MODEL`, and +`OpenAIChatCompletionClient(model="...")` ignores `OPENAI_CHAT_COMPLETION_MODEL`. + ### Azure OpenAI These variables are used when the client is configured for Azure OpenAI: @@ -56,16 +60,19 @@ These variables are used when the client is configured for Azure OpenAI: | `AZURE_OPENAI_BASE_URL` | Full Azure OpenAI base URL (`.../openai/v1`) | | `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI API version | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Generic fallback deployment | -| `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatClient` | -| `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatCompletionClient` | -| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIEmbeddingClient` | +| `AZURE_OPENAI_MODEL` | Generic fallback deployment | +| `AZURE_OPENAI_CHAT_MODEL` | Preferred deployment for `OpenAIChatClient` | +| `AZURE_OPENAI_CHAT_COMPLETION_MODEL` | Preferred deployment for `OpenAIChatCompletionClient` | +| `AZURE_OPENAI_EMBEDDING_MODEL` | Preferred deployment for `OpenAIEmbeddingClient` | Deployment lookup order: -- `OpenAIChatClient`: `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` -- `OpenAIChatCompletionClient`: `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` -- `OpenAIEmbeddingClient`: `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` +- `OpenAIChatClient`: `AZURE_OPENAI_CHAT_MODEL` -> `AZURE_OPENAI_MODEL` +- `OpenAIChatCompletionClient`: `AZURE_OPENAI_CHAT_COMPLETION_MODEL` -> `AZURE_OPENAI_MODEL` +- `OpenAIEmbeddingClient`: `AZURE_OPENAI_EMBEDDING_MODEL` -> `AZURE_OPENAI_MODEL` + +For Azure routing, the same rule applies: the client-specific deployment variable is checked first, +then the generic `AZURE_OPENAI_MODEL` fallback. Passing `model=` overrides both environment variables. When both OpenAI and Azure environment variables are present, the generic clients prefer OpenAI when `OPENAI_API_KEY` is configured. To use Azure explicitly, pass `azure_endpoint` or diff --git a/python/packages/openai/agent_framework_openai/__init__.py b/python/packages/openai/agent_framework_openai/__init__.py index 5744c16b43..68e00c39db 100644 --- a/python/packages/openai/agent_framework_openai/__init__.py +++ b/python/packages/openai/agent_framework_openai/__init__.py @@ -7,19 +7,7 @@ including clients for the Responses API and Chat Completions API. """ import importlib.metadata -import sys -if sys.version_info >= (3, 13): - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import deprecated # type: ignore # pragma: no cover - -from ._assistant_provider import OpenAIAssistantProvider -from ._assistants_client import ( - AssistantToolResources, - OpenAIAssistantsClient, # type: ignore[reportDeprecated] - OpenAIAssistantsOptions, -) from ._chat_client import ( OpenAIChatClient, OpenAIChatOptions, @@ -40,35 +28,8 @@ try: except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode -# Deprecated aliases for old names — use subclasses so the warning only fires for the alias - - -@deprecated( - "OpenAIResponsesClient is deprecated, use OpenAIChatClient instead.", - category=DeprecationWarning, -) -class OpenAIResponsesClient(OpenAIChatClient): # type: ignore[misc] - """Deprecated alias for :class:`OpenAIChatClient`.""" - - -@deprecated( - "RawOpenAIResponsesClient is deprecated, use RawOpenAIChatClient instead.", - category=DeprecationWarning, -) -class RawOpenAIResponsesClient(RawOpenAIChatClient): # type: ignore[misc] - """Deprecated alias for :class:`RawOpenAIChatClient`.""" - - -OpenAIResponsesOptions = OpenAIChatOptions -"""Deprecated alias for :class:`OpenAIChatOptions`.""" - - __all__ = [ - "AssistantToolResources", "ContentFilterResultSeverity", - "OpenAIAssistantProvider", - "OpenAIAssistantsClient", - "OpenAIAssistantsOptions", "OpenAIChatClient", "OpenAIChatCompletionClient", "OpenAIChatCompletionOptions", @@ -77,11 +38,8 @@ __all__ = [ "OpenAIContinuationToken", "OpenAIEmbeddingClient", "OpenAIEmbeddingOptions", - "OpenAIResponsesClient", - "OpenAIResponsesOptions", "OpenAISettings", "RawOpenAIChatClient", "RawOpenAIChatCompletionClient", - "RawOpenAIResponsesClient", "__version__", ] diff --git a/python/packages/openai/agent_framework_openai/_assistant_provider.py b/python/packages/openai/agent_framework_openai/_assistant_provider.py deleted file mode 100644 index f899607039..0000000000 --- a/python/packages/openai/agent_framework_openai/_assistant_provider.py +++ /dev/null @@ -1,564 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence -from typing import TYPE_CHECKING, Any, Generic, cast - -from agent_framework._agents import Agent -from agent_framework._middleware import MiddlewareTypes -from agent_framework._sessions import BaseContextProvider -from agent_framework._settings import SecretString, load_settings -from agent_framework._tools import FunctionTool, ToolTypes, normalize_tools -from openai import AsyncOpenAI -from openai.types.beta.assistant import Assistant -from pydantic import BaseModel - -from ._assistants_client import OpenAIAssistantsClient # type: ignore[reportDeprecated] -from ._shared import OpenAISettings, from_assistant_tools, to_assistant_tools - -if TYPE_CHECKING: - from ._assistants_client import OpenAIAssistantsOptions - -if sys.version_info >= (3, 13): - from typing import TypeVar # type:ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type:ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type:ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type:ignore # pragma: no cover - - -# Type variable for options - allows typed OpenAIAssistantProvider[OptionsCoT] returns -# Default matches OpenAIAssistantsClient's default options type -OptionsCoT = TypeVar( - "OptionsCoT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIAssistantsOptions", - covariant=True, -) - - -class OpenAIAssistantProvider(Generic[OptionsCoT]): - """Provider for creating Agent instances from OpenAI Assistants API. - - This provider allows you to create, retrieve, and wrap OpenAI Assistants - as Agent instances for use in the agent framework. - - Examples: - Basic usage with automatic client creation: - - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantProvider - - # Uses OPENAI_API_KEY environment variable - provider = OpenAIAssistantProvider() - - # Create a new assistant - agent = await provider.create_agent( - name="MyAssistant", - model="gpt-4", - instructions="You are a helpful assistant.", - tools=[my_function], - ) - - result = await agent.run("Hello!") - - Using an existing client: - - .. code-block:: python - - from openai import AsyncOpenAI - from agent_framework.openai import OpenAIAssistantProvider - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Get an existing assistant by ID - agent = await provider.get_agent( - assistant_id="asst_123", - tools=[my_function], # Provide implementations for function tools - ) - - Wrapping an SDK Assistant object: - - .. code-block:: python - - # Fetch assistant directly via SDK - assistant = await client.beta.assistants.retrieve("asst_123") - - # Wrap without additional HTTP call - agent = provider.as_agent(assistant, tools=[my_function]) - """ - - def __init__( - self, - client: AsyncOpenAI | None = None, - *, - api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize the OpenAI Assistant Provider. - - Args: - client: An existing AsyncOpenAI client to use. If not provided, - a new client will be created using the other parameters. - - Keyword Args: - api_key: OpenAI API key. Can also be set via OPENAI_API_KEY env var. - org_id: OpenAI organization ID. Can also be set via OPENAI_ORG_ID env var. - base_url: Base URL for the OpenAI API. Can also be set via OPENAI_BASE_URL env var. - env_file_path: Path to .env file for configuration. - env_file_encoding: Encoding of the .env file. - - Raises: - ValueError: If no client is provided and API key is missing. - - Examples: - .. code-block:: python - - # Using environment variables - provider = OpenAIAssistantProvider() - - # Using explicit API key - provider = OpenAIAssistantProvider(api_key="sk-...") - - # Using existing client - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - """ - self._client: AsyncOpenAI | None = client - self._should_close_client: bool = client is None - - if client is None: - # Load settings and create client - settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - api_key_setting = settings.get("api_key") - if not api_key_setting: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - - # Get API key value - api_key_value: str | Callable[[], str | Awaitable[str]] - if isinstance(api_key_setting, SecretString): - api_key_value = api_key_setting.get_secret_value() - else: - api_key_value = api_key_setting - - # Create client - client_args: dict[str, Any] = {"api_key": api_key_value} - if org_id_value := settings.get("org_id"): - client_args["organization"] = org_id_value - if base_url_value := settings.get("base_url"): - client_args["base_url"] = base_url_value - - self._client = AsyncOpenAI(**client_args) - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the provider and clean up resources. - - If the provider created its own client, it will be closed. - If an external client was provided, it will not be closed. - """ - if self._should_close_client and self._client is not None: - await self._client.close() - - async def create_agent( - self, - *, - name: str, - model: str, - instructions: str | None = None, - description: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - metadata: dict[str, str] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a new assistant on OpenAI and return a Agent. - - This method creates a new assistant on the OpenAI service and wraps it - in a Agent instance. The assistant will persist on OpenAI until deleted. - - Keyword Args: - name: The name of the assistant (required). - model: The model ID to use, e.g., "gpt-4", "gpt-4o" (required). - instructions: System instructions for the assistant. - description: A description of the assistant. - tools: Tools available to the assistant. Can include: - - FunctionTool instances or callables decorated with @tool - - Dict-based tools from OpenAIAssistantsClient.get_code_interpreter_tool() - - Dict-based tools from OpenAIAssistantsClient.get_file_search_tool() - - Raw tool dictionaries - metadata: Metadata to attach to the assistant (max 16 key-value pairs). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - Include ``response_format`` here for structured output responses. - middleware: MiddlewareTypes for the Agent. - context_providers: Context providers for the Agent. - - Returns: - A Agent instance wrapping the created assistant. - - Raises: - ValueError: If assistant creation fails. - - Examples: - .. code-block:: python - - provider = OpenAIAssistantProvider() - - # Create with function tools - agent = await provider.create_agent( - name="WeatherBot", - model="gpt-4", - instructions="You are a helpful weather assistant.", - tools=[get_weather], - ) - - # Create with structured output - agent = await provider.create_agent( - name="StructuredBot", - model="gpt-4", - default_options={"response_format": MyPydanticModel}, - ) - """ - # Normalize tools - normalized_tools = normalize_tools(tools) - assistant_tools: list[FunctionTool | MutableMapping[str, Any]] = [ - tool for tool in normalized_tools if isinstance(tool, (FunctionTool, MutableMapping)) - ] - api_tools = to_assistant_tools(assistant_tools) if assistant_tools else [] - - # Extract response_format from default_options if present - opts = dict(default_options) if default_options else {} - response_format = opts.get("response_format") - - # Build assistant creation parameters - create_params: dict[str, Any] = { - "model": model, - "name": name, - } - - if instructions is not None: - create_params["instructions"] = instructions - if description is not None: - create_params["description"] = description - if api_tools: - create_params["tools"] = api_tools - if metadata is not None: - create_params["metadata"] = metadata - - # Handle response format for OpenAI API - if response_format is not None and isinstance(response_format, type) and issubclass(response_format, BaseModel): - create_params["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__, - "schema": response_format.model_json_schema(), - "strict": True, - }, - } - - # Create the assistant - if not self._client: - raise RuntimeError("OpenAI client is not initialized.") - - assistant = await self._client.beta.assistants.create(**create_params) # type: ignore[reportDeprecated] - - # Create Agent - pass default_options which contains response_format - return self._create_chat_agent_from_assistant( - assistant=assistant, - tools=normalized_tools, - instructions=instructions, - middleware=middleware, - context_providers=context_providers, - default_options=default_options, - ) - - async def get_agent( - self, - assistant_id: str, - *, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - instructions: str | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Retrieve an existing assistant by ID and return a Agent. - - This method fetches an existing assistant from OpenAI by its ID - and wraps it in a Agent instance. - - Args: - assistant_id: The ID of the assistant to retrieve (e.g., "asst_123"). - - Keyword Args: - tools: Function tools to make available. IMPORTANT: If the assistant - was created with function tools, you MUST provide matching - implementations here. Hosted tools (code_interpreter, file_search) - are automatically included. - instructions: Override the assistant's instructions (optional). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: MiddlewareTypes for the Agent. - context_providers: Context providers for the Agent. - - Returns: - A Agent instance wrapping the retrieved assistant. - - Raises: - RuntimeError: If the assistant cannot be retrieved. - ValueError: If required function tools are missing. - - Examples: - .. code-block:: python - - provider = OpenAIAssistantProvider() - - # Get assistant without function tools - agent = await provider.get_agent(assistant_id="asst_123") - - # Get assistant with function tools - agent = await provider.get_agent( - assistant_id="asst_456", - tools=[get_weather, search_database], # Implementations required! - ) - """ - # Fetch the assistant - if not self._client: - raise RuntimeError("OpenAI client is not initialized.") - - assistant = await self._client.beta.assistants.retrieve(assistant_id) # type: ignore[reportDeprecated] - - # Use as_agent to wrap it - return self.as_agent( - assistant=assistant, - tools=tools, - instructions=instructions, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def as_agent( - self, - assistant: Assistant, - *, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - instructions: str | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Wrap an existing SDK Assistant object as a Agent. - - This method does NOT make any HTTP calls. It simply wraps an already- - fetched Assistant object in a Agent. - - Args: - assistant: The OpenAI Assistant SDK object to wrap. - - Keyword Args: - tools: Function tools to make available. If the assistant has - function tools defined, you MUST provide matching implementations. - Hosted tools (code_interpreter, file_search) are automatically included. - instructions: Override the assistant's instructions (optional). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: MiddlewareTypes for the Agent. - context_providers: Context providers for the Agent. - - Returns: - A Agent instance wrapping the assistant. - - Raises: - ValueError: If required function tools are missing. - - Examples: - .. code-block:: python - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Fetch assistant via SDK - assistant = await client.beta.assistants.retrieve("asst_123") - - # Wrap without additional HTTP call - agent = provider.as_agent( - assistant, - tools=[my_function], - instructions="Custom instructions override", - ) - """ - # Validate that required function tools are provided - self._validate_function_tools(assistant.tools or [], tools) - - # Merge hosted tools with user-provided function tools - merged_tools = self._merge_tools(assistant.tools or [], tools) - - # Create Agent - return self._create_chat_agent_from_assistant( - assistant=assistant, - tools=merged_tools, - instructions=instructions, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def _validate_function_tools( - self, - assistant_tools: list[Any], - provided_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, - ) -> None: - """Validate that required function tools are provided. - - Args: - assistant_tools: Tools defined on the assistant. - provided_tools: Tools provided by the user. - - Raises: - ValueError: If a required function tool is missing. - """ - # Get function tool names from assistant - required_functions: set[str] = set() - for tool in assistant_tools: - if ( - hasattr(tool, "type") - and tool.type == "function" - and hasattr(tool, "function") - and hasattr(tool.function, "name") - ): - required_functions.add(tool.function.name) - - if not required_functions: - return # No function tools required - - # Get provided function names using normalize_tools - provided_functions: set[str] = set() - if provided_tools is not None: - normalized = normalize_tools(provided_tools) - for tool in normalized: - if isinstance(tool, FunctionTool): - provided_functions.add(tool.name) - elif isinstance(tool, Mapping): - typed_tool = cast(Mapping[str, Any], tool) - raw_func_spec = typed_tool.get("function") - if isinstance(raw_func_spec, Mapping): - typed_func_spec = cast(Mapping[str, Any], raw_func_spec) - raw_name = typed_func_spec.get("name") - if isinstance(raw_name, str) and raw_name: - provided_functions.add(raw_name) - - # Check for missing functions - missing = required_functions - provided_functions - if missing: - missing_list = ", ".join(sorted(missing)) - raise ValueError( - f"Assistant requires function tool(s) '{missing_list}' but no implementation was provided. " - f"Please pass the function implementation(s) in the 'tools' parameter." - ) - - def _merge_tools( - self, - assistant_tools: list[Any], - user_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, - ) -> list[FunctionTool | MutableMapping[str, Any] | Any]: - """Merge hosted tools from assistant with user-provided function tools. - - Args: - assistant_tools: Tools defined on the assistant. - user_tools: Tools provided by the user. - - Returns: - A list of all tools (hosted tools + user function implementations). - """ - merged: list[FunctionTool | MutableMapping[str, Any] | Any] = [] - - # Add hosted tools from assistant using shared conversion - hosted_tools = from_assistant_tools(assistant_tools) - merged.extend(hosted_tools) - - # Add user-provided tools (normalized) - if user_tools is not None: - normalized_user_tools = normalize_tools(user_tools) - merged.extend(normalized_user_tools) - - return merged - - def _create_chat_agent_from_assistant( - self, - assistant: Assistant, - tools: list[FunctionTool | MutableMapping[str, Any] | Any] | None, - instructions: str | None, - middleware: Sequence[MiddlewareTypes] | None, - context_providers: Sequence[BaseContextProvider] | None, - default_options: OptionsCoT | None = None, - **kwargs: Any, - ) -> Agent[OptionsCoT]: - """Create a Agent from an Assistant. - - Args: - assistant: The OpenAI Assistant object. - tools: Tools for the agent. - instructions: Instructions override. - middleware: MiddlewareTypes for the agent. - context_providers: Context providers for the agent. - default_options: Default chat options for the agent (may include response_format). - **kwargs: Additional arguments passed to Agent. - - Returns: - A configured Agent instance. - """ - # Create the chat client with the assistant - client = OpenAIAssistantsClient( # type: ignore[reportDeprecated] - model=assistant.model, - assistant_id=assistant.id, - assistant_name=assistant.name, - assistant_description=assistant.description, - async_client=self._client, - ) - - # Use instructions from assistant if not overridden - final_instructions = instructions if instructions is not None else assistant.instructions - - # Create and return Agent - return Agent( - client=client, - id=assistant.id, - name=assistant.name, - description=assistant.description, - instructions=final_instructions, - tools=tools if tools else None, - middleware=middleware, - context_providers=context_providers, - default_options=default_options, # type: ignore[arg-type] - **kwargs, - ) diff --git a/python/packages/openai/agent_framework_openai/_assistants_client.py b/python/packages/openai/agent_framework_openai/_assistants_client.py deleted file mode 100644 index 14aa764492..0000000000 --- a/python/packages/openai/agent_framework_openai/_assistants_client.py +++ /dev/null @@ -1,968 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import json -import logging -import sys -from collections.abc import ( - AsyncIterable, - Awaitable, - Callable, - Mapping, - MutableMapping, - Sequence, -) -from typing import TYPE_CHECKING, Any, Generic, Literal, TypedDict, cast - -from agent_framework._clients import BaseChatClient -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._tools import ( - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - normalize_tools, -) -from agent_framework._types import ( - Annotation, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - ResponseStream, - TextSpanRegion, - UsageDetails, -) -from agent_framework.observability import ChatTelemetryLayer -from openai import AsyncOpenAI -from openai.types.beta.threads import ( - FileCitationAnnotation, - FileCitationDeltaAnnotation, - FilePathAnnotation, - FilePathDeltaAnnotation, - ImageURLContentBlockParam, - ImageURLParam, - MessageContentPartParam, - MessageDeltaEvent, - Run, - TextContentBlockParam, - TextDeltaBlock, -) -from openai.types.beta.threads import ( - Message as ThreadMessage, -) -from openai.types.beta.threads.run_create_params import AdditionalMessage -from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput -from openai.types.beta.threads.runs import RunStep -from pydantic import BaseModel - -from ._shared import OpenAIConfigMixin, OpenAISettings - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover - -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore # pragma: no cover - -if sys.version_info >= (3, 13): - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import deprecated # type: ignore # pragma: no cover - -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - -logger = logging.getLogger("agent_framework.openai") - - -# region OpenAI Assistants Options TypedDict - -ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) - - -class VectorStoreToolResource(TypedDict, total=False): - """Vector store configuration for file search tool resources.""" - - vector_store_ids: list[str] - """IDs of vector stores attached to this assistant.""" - - -class CodeInterpreterToolResource(TypedDict, total=False): - """Code interpreter tool resource configuration.""" - - file_ids: list[str] - """File IDs accessible by the code interpreter tool. Max 20 files per assistant.""" - - -class AssistantToolResources(TypedDict, total=False): - """Tool resources attached to the assistant. - - See: https://platform.openai.com/docs/api-reference/assistants/createAssistant#assistants-createassistant-tool_resources - """ - - code_interpreter: CodeInterpreterToolResource - """Resources for code interpreter tool, including file IDs.""" - - file_search: VectorStoreToolResource - """Resources for file search tool, including vector store IDs.""" - - -class OpenAIAssistantsOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): - """OpenAI Assistants API-specific options dict. - - Extends base ChatOptions with Assistants API-specific parameters - for creating and running assistants. - - See: https://platform.openai.com/docs/api-reference/assistants - - Keys: - # Inherited from ChatOptions: - model_id: Deprecated. The model to use for the assistant, - translates to ``model`` in OpenAI API. - temperature: Sampling temperature between 0 and 2. - top_p: Nucleus sampling parameter. - max_tokens: Maximum number of tokens to generate, - translates to ``max_completion_tokens`` in OpenAI API. - tools: List of tools (functions, code_interpreter, file_search). - tool_choice: How the model should use tools. - allow_multiple_tool_calls: Whether to allow parallel tool calls, - translates to ``parallel_tool_calls`` in OpenAI API. - response_format: Structured output schema. - metadata: Request metadata for tracking. - - # Options not supported in Assistants API (inherited but unused): - stop: Not supported. - seed: Not supported (use assistant-level configuration instead). - frequency_penalty: Not supported. - presence_penalty: Not supported. - user: Not supported. - store: Not supported. - - # Assistants-specific options: - name: Name of the assistant. - description: Description of the assistant. - instructions: System instructions for the assistant. - tool_resources: Resources for tools (file IDs, vector stores). - reasoning_effort: Effort level for o-series reasoning models. - conversation_id: Thread ID to continue conversation in. - """ - - # Assistants-specific options - name: str - """Name of the assistant (max 256 characters).""" - - description: str - """Description of the assistant (max 512 characters).""" - - tool_resources: AssistantToolResources - """Tool-specific resources like file IDs and vector stores.""" - - reasoning_effort: Literal["low", "medium", "high"] - """Effort level for o-series reasoning models (o1, o3-mini). - Higher effort = more reasoning time and potentially better results.""" - - conversation_id: str # type: ignore[misc] - """Thread ID to continue a conversation in an existing thread.""" - - # OpenAI/ChatOptions fields not supported in Assistants API - stop: None # type: ignore[misc] - """Not supported in Assistants API.""" - - seed: None # type: ignore[misc] - """Not supported in Assistants API (use assistant-level configuration).""" - - frequency_penalty: None # type: ignore[misc] - """Not supported in Assistants API.""" - - presence_penalty: None # type: ignore[misc] - """Not supported in Assistants API.""" - - user: None # type: ignore[misc] - """Not supported in Assistants API.""" - - store: None # type: ignore[misc] - """Not supported in Assistants API.""" - - -ASSISTANTS_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", # backward compat: accept model_id in options - "max_tokens": "max_completion_tokens", - "allow_multiple_tool_calls": "parallel_tool_calls", -} -"""Maps ChatOptions keys to OpenAI Assistants API parameter names.""" - -OpenAIAssistantsOptionsT = TypeVar( - "OpenAIAssistantsOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIAssistantsOptions", - covariant=True, -) - - -# endregion - - -@deprecated("OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead.") -class OpenAIAssistantsClient( # type: ignore[misc] - OpenAIConfigMixin, - FunctionInvocationLayer[OpenAIAssistantsOptionsT], - ChatMiddlewareLayer[OpenAIAssistantsOptionsT], - ChatTelemetryLayer[OpenAIAssistantsOptionsT], - BaseChatClient[OpenAIAssistantsOptionsT], - Generic[OpenAIAssistantsOptionsT], -): - """OpenAI Assistants client with middleware, telemetry, and function invocation support. - - .. deprecated:: - OpenAIAssistantsClient is deprecated. Use :class:`OpenAIChatClient` instead. - """ - - # region Hosted Tool Factory Methods - - @staticmethod - def get_code_interpreter_tool() -> dict[str, Any]: - """Create a code interpreter tool configuration for the Assistants API. - - Returns: - A dict tool configuration ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantsClient - - # Enable code interpreter - tool = OpenAIAssistantsClient.get_code_interpreter_tool() - - agent = ChatAgent(client, tools=[tool]) - """ - return {"type": "code_interpreter"} - - @staticmethod - def get_file_search_tool( - *, - max_num_results: int | None = None, - ) -> dict[str, Any]: - """Create a file search tool configuration for the Assistants API. - - Keyword Args: - max_num_results: Maximum number of results to return from file search. - - Returns: - A dict tool configuration ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantsClient - - # Basic file search - tool = OpenAIAssistantsClient.get_file_search_tool() - - # With result limit - tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10) - - agent = ChatAgent(client, tools=[tool]) - """ - tool: dict[str, Any] = {"type": "file_search"} - - if max_num_results is not None: - tool["file_search"] = {"max_num_results": max_num_results} - - return tool - - # endregion - - def __init__( - self, - *, - model: str | None = None, - model_id: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - assistant_description: str | None = None, - thread_id: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, - ) -> None: - """Initialize an OpenAI Assistants client. - - Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - model_id: Deprecated alias for ``model``. - assistant_id: The ID of an OpenAI assistant to use. - If not provided, a new assistant will be created (and deleted after the request). - assistant_name: The name to use when creating new assistants. - assistant_description: The description to use when creating new assistants. - thread_id: Default thread ID to use for conversations. Can be overridden by - conversation_id property when making a request. - If not provided, a new thread will be created (and deleted after the request). - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - base_url: The base URL to use. If provided will override the standard value. - Can also be set via environment variable OPENAI_BASE_URL. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. - middleware: Optional sequence of middleware to apply to requests. - function_invocation_configuration: Optional configuration for function invocation behavior. - kwargs: Other keyword parameters. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantsClient - - # Using environment variables - # Set OPENAI_API_KEY=sk-... - # Set OPENAI_MODEL=gpt-4 - client = OpenAIAssistantsClient() - - # Or passing parameters directly - client = OpenAIAssistantsClient(model="gpt-4", api_key="sk-...") - - # Or loading from a .env file - client = OpenAIAssistantsClient(env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework.openai import OpenAIAssistantsOptions - - - class MyOptions(OpenAIAssistantsOptions, total=False): - my_custom_option: str - - - client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model="gpt-4") - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - base_url=base_url, - org_id=org_id, - model=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - api_key_value = openai_settings.get("api_key") - if not async_client and not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - - resolved_model = openai_settings.get("model") - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via 'model' parameter or 'OPENAI_MODEL' environment variable." - ) - - super().__init__( - model=resolved_model, - api_key=self._get_api_key(api_key_value), - org_id=openai_settings.get("org_id"), - default_headers=default_headers, - client=async_client, - base_url=openai_settings.get("base_url"), - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - self.assistant_id: str | None = assistant_id - self.assistant_name: str | None = assistant_name - self.assistant_description: str | None = assistant_description - self.thread_id: str | None = thread_id - self._should_delete_assistant: bool = False - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: Any, - ) -> None: - """Async context manager exit - clean up any assistants we created.""" - await self.close() - - async def close(self) -> None: - """Clean up any assistants we created.""" - if self._should_delete_assistant and self.assistant_id is not None: - client = await self._ensure_client() - await client.beta.assistants.delete(self.assistant_id) # type: ignore[reportDeprecated] - object.__setattr__(self, "assistant_id", None) - object.__setattr__(self, "_should_delete_assistant", False) - - @override - def _inner_get_response( - self, - *, - messages: Sequence[Message], - options: Mapping[str, Any], - stream: bool = False, - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - if stream: - # Streaming mode - return the async generator directly - async def _stream() -> AsyncIterable[ChatResponseUpdate]: - # prepare - run_options, tool_results = self._prepare_options(messages, options, **kwargs) - - # Get the thread ID - thread_id: str | None = options.get( - "conversation_id", run_options.get("conversation_id", self.thread_id) - ) - - if thread_id is None and tool_results is not None: - raise ValueError("No thread ID was provided, but chat messages includes tool results.") - - # Determine which assistant to use and create if needed - assistant_id = await self._get_assistant_id_or_create() - - # execute - stream_obj, thread_id = await self._create_assistant_stream( - thread_id, assistant_id, run_options, tool_results - ) - - # process - async for update in self._process_stream_events(stream_obj, thread_id): - yield update - - return self._build_response_stream(_stream(), response_format=options.get("response_format")) - - # Non-streaming mode - collect updates and convert to response - async def _get_response() -> ChatResponse: - stream_result = self._inner_get_response(messages=messages, options=options, stream=True, **kwargs) - return await ChatResponse.from_update_generator( - updates=stream_result, # type: ignore[arg-type] - output_format_type=options.get("response_format"), # type: ignore[arg-type] - ) - - return _get_response() - - async def _get_assistant_id_or_create(self) -> str: - """Determine which assistant to use and create if needed. - - Returns: - str: The assistant_id to use. - """ - # If no assistant is provided, create a temporary assistant - if self.assistant_id is None: - if not self.model: - raise ValueError("Parameter 'model' is required for assistant creation.") - - client = await self._ensure_client() - created_assistant = await client.beta.assistants.create( # type: ignore[reportDeprecated] - model=self.model, - description=self.assistant_description, - name=self.assistant_name, - ) - self.assistant_id = created_assistant.id - self._should_delete_assistant = True - - return self.assistant_id - - async def _create_assistant_stream( - self, - thread_id: str | None, - assistant_id: str, - run_options: dict[str, Any], - tool_results: list[Content] | None, - ) -> tuple[Any, str]: - """Create the assistant stream for processing. - - Returns: - tuple: (stream, final_thread_id) - """ - client = await self._ensure_client() - # Get any active run for this thread - thread_run = await self._get_active_thread_run(thread_id) - - tool_run_id, tool_outputs = self._prepare_tool_outputs_for_assistants(tool_results) - - if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs: - # There's an active run and we have tool results to submit, so submit the results. - stream = client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated] - run_id=tool_run_id, - thread_id=thread_run.thread_id, - tool_outputs=tool_outputs, - ) - final_thread_id = thread_run.thread_id - else: - # Handle thread creation or cancellation - final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options) - - # Now create a new run and stream the results. - stream = client.beta.threads.runs.stream( # type: ignore[reportDeprecated] - assistant_id=assistant_id, thread_id=final_thread_id, **run_options - ) - - return stream, final_thread_id - - async def _get_active_thread_run(self, thread_id: str | None) -> Run | None: - """Get any active run for the given thread.""" - client = await self._ensure_client() - if thread_id is None: - return None - - async for run in client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated] - if run.status not in ["completed", "cancelled", "failed", "expired"]: - return run - return None - - async def _prepare_thread(self, thread_id: str | None, thread_run: Run | None, run_options: dict[str, Any]) -> str: - """Prepare the thread for a new run, creating or cleaning up as needed.""" - client = await self._ensure_client() - if thread_id is None: - # No thread ID was provided, so create a new thread. - thread = await client.beta.threads.create( # type: ignore[reportDeprecated] - messages=run_options["additional_messages"], - tool_resources=run_options.get("tool_resources"), - metadata=run_options.get("metadata"), - ) - run_options["additional_messages"] = [] - run_options.pop("tool_resources", None) - return thread.id - - if thread_run is not None: - # There was an active run; we need to cancel it before starting a new run. - await client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated] - - return thread_id - - async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIterable[ChatResponseUpdate]: - response_id: str | None = None - - async with stream as response_stream: - async for response in response_stream: - if response.event == "thread.run.created": - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - role="assistant", - ) - elif response.event == "thread.run.step.created" and isinstance(response.data, RunStep): - response_id = response.data.run_id - elif response.event == "thread.message.delta" and isinstance(response.data, MessageDeltaEvent): - delta = response.data.delta - role = "user" if delta.role == "user" else "assistant" - - for delta_block in delta.content or []: - if isinstance(delta_block, TextDeltaBlock) and delta_block.text and delta_block.text.value: - text_content = Content.from_text(delta_block.text.value) - if delta_block.text.annotations: - annotations: list[Annotation] = [] - text_content.annotations = annotations - for annotation in delta_block.text.annotations: - if isinstance(annotation, FileCitationDeltaAnnotation): - ann: Annotation = Annotation( - type="citation", - additional_properties={ - "text": annotation.text, - "index": annotation.index, - }, - raw_representation=annotation, - ) - if annotation.file_citation and annotation.file_citation.file_id: - ann["file_id"] = annotation.file_citation.file_id - if annotation.start_index is not None and annotation.end_index is not None: - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, - ) - ] - annotations.append(ann) - elif isinstance(annotation, FilePathDeltaAnnotation): - ann = Annotation( - type="citation", - additional_properties={ - "text": annotation.text, - "index": annotation.index, - }, - raw_representation=annotation, - ) - if annotation.file_path and annotation.file_path.file_id: - ann["file_id"] = annotation.file_path.file_id - if annotation.start_index is not None and annotation.end_index is not None: - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, - ) - ] - annotations.append(ann) - yield ChatResponseUpdate( - role=role, # type: ignore[arg-type] - contents=[text_content], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - elif response.event == "thread.message.completed" and isinstance(response.data, ThreadMessage): - # Process completed message to extract fully resolved annotations. - # Delta events may carry partial/empty annotation data; the completed - # message contains the final text with all citation details populated. - completed_contents: list[Content] = [] - for block in response.data.content: - if block.type != "text": - continue - text_content = Content.from_text(block.text.value) - if block.text.annotations: - completed_annotations: list[Annotation] = [] - text_content.annotations = completed_annotations - for completed_annotation in block.text.annotations: - if isinstance(completed_annotation, FileCitationAnnotation): - props: dict[str, Any] = { - "text": completed_annotation.text, - } - ann = Annotation( - type="citation", - additional_properties=props, - raw_representation=completed_annotation, - ) - if ( - completed_annotation.file_citation - and completed_annotation.file_citation.file_id - ): - ann["file_id"] = completed_annotation.file_citation.file_id - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=completed_annotation.start_index, - end_index=completed_annotation.end_index, - ) - ] - text_content.annotations.append(ann) - elif isinstance(completed_annotation, FilePathAnnotation): - ann = Annotation( - type="citation", - additional_properties={ - "text": completed_annotation.text, - }, - raw_representation=completed_annotation, - ) - if completed_annotation.file_path and completed_annotation.file_path.file_id: - ann["file_id"] = completed_annotation.file_path.file_id - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=completed_annotation.start_index, - end_index=completed_annotation.end_index, - ) - ] - text_content.annotations.append(ann) - else: - logger.debug("Unparsed annotation type: %s", completed_annotation.type) - completed_contents.append(text_content) - if completed_contents: - yield ChatResponseUpdate( - role="assistant", - contents=completed_contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - elif response.event == "thread.run.requires_action" and isinstance(response.data, Run): - contents = self._parse_function_calls_from_assistants(response.data, response_id) - if contents: - yield ChatResponseUpdate( - role="assistant", - contents=contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - elif ( - response.event == "thread.run.completed" - and isinstance(response.data, Run) - and response.data.usage is not None - ): - usage = response.data.usage - usage_content = Content.from_usage( - UsageDetails( - input_token_count=usage.prompt_tokens, - output_token_count=usage.completion_tokens, - total_token_count=usage.total_tokens, - ) - ) - yield ChatResponseUpdate( - role="assistant", - contents=[usage_content], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - else: - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - role="assistant", - ) - - def _parse_function_calls_from_assistants(self, event_data: Run, response_id: str | None) -> list[Content]: - """Parse function call contents from an assistants tool action event.""" - contents: list[Content] = [] - - if event_data.required_action is not None: - for tool_call in event_data.required_action.submit_tool_outputs.tool_calls: - tool_call_any = cast(Any, tool_call) - call_id = json.dumps([response_id, tool_call.id]) - tool_type = getattr(tool_call, "type", None) - if tool_type == "code_interpreter" and getattr(tool_call_any, "code_interpreter", None): - code_input = getattr(tool_call_any.code_interpreter, "input", None) - inputs = ( - [Content.from_text(text=code_input, raw_representation=tool_call)] - if code_input is not None - else None - ) - contents.append( - Content.from_code_interpreter_tool_call( - call_id=call_id, - inputs=inputs, - raw_representation=tool_call, - ) - ) - elif tool_type == "mcp": - contents.append( - Content.from_mcp_server_tool_call( - call_id=call_id, - tool_name=getattr(tool_call, "name", "") or "", - server_name=getattr(tool_call, "server_label", None), - arguments=getattr(tool_call, "args", None), - raw_representation=tool_call, - ) - ) - else: - function_name = tool_call.function.name - function_arguments = json.loads(tool_call.function.arguments) - contents.append( - Content.from_function_call( - call_id=call_id, - name=function_name, - arguments=function_arguments, - ) - ) - - return contents - - def _prepare_options( - self, - messages: Sequence[Message], - options: Mapping[str, Any], - **kwargs: Any, - ) -> tuple[dict[str, Any], list[Content] | None]: - from agent_framework._types import validate_tool_mode - - run_options: dict[str, Any] = {**kwargs} - - # Extract options from the dict - max_tokens = options.get("max_tokens") - model = options.get("model") or options.get("model_id") # backward compat - top_p = options.get("top_p") - temperature = options.get("temperature") - allow_multiple_tool_calls = options.get("allow_multiple_tool_calls") - tool_choice = options.get("tool_choice") - tools = options.get("tools") - response_format = options.get("response_format") - tool_resources = options.get("tool_resources") - - if max_tokens is not None: - run_options["max_completion_tokens"] = max_tokens - if model is not None: - run_options["model"] = model - if top_p is not None: - run_options["top_p"] = top_p - if temperature is not None: - run_options["temperature"] = temperature - - if allow_multiple_tool_calls is not None: - run_options["parallel_tool_calls"] = allow_multiple_tool_calls - - if tool_resources is not None: - run_options["tool_resources"] = tool_resources - - tool_mode = validate_tool_mode(tool_choice) - tool_definitions: list[MutableMapping[str, Any]] = [] - # Always include tools if provided, regardless of tool_choice - # tool_choice="none" means the model won't call tools, but tools should still be available - for tool in normalize_tools(tools): - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] - elif isinstance(tool, MutableMapping): - # Pass through dict-based tools directly (from static factory methods) - tool_definitions.append(cast(MutableMapping[str, Any], tool)) - - if len(tool_definitions) > 0: - run_options["tools"] = tool_definitions - - if tool_mode is not None: - mode = tool_mode.get("mode") - if mode is None: - raise ValueError("tool_choice mode is required") - if mode == "required" and (func_name := tool_mode.get("required_function_name")) is not None: - run_options["tool_choice"] = { - "type": "function", - "function": {"name": func_name}, - } - else: - run_options["tool_choice"] = mode - - if response_format is not None: - if isinstance(response_format, dict): - run_options["response_format"] = response_format - else: - run_options["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__, - "schema": response_format.model_json_schema(), - "strict": True, - }, - } - - instructions: list[str] = [] - tool_results: list[Content] | None = None - - additional_messages: list[AdditionalMessage] | None = None - - # System/developer messages are turned into instructions, - # since there is no such message roles in OpenAI Assistants. - # All other messages are added 1:1. - for chat_message in messages: - if chat_message.role in ["system", "developer"]: - for text_content in [content for content in chat_message.contents if content.type == "text"]: - text = getattr(text_content, "text", None) - if text: - instructions.append(text) - - continue - - message_contents: list[MessageContentPartParam] = [] - - for content in chat_message.contents: - if content.type == "text": - message_contents.append(TextContentBlockParam(type="text", text=content.text)) # type: ignore[attr-defined, typeddict-item] - elif content.type == "uri" and content.has_top_level_media_type("image"): - message_contents.append( - ImageURLContentBlockParam(type="image_url", image_url=ImageURLParam(url=content.uri)) # type: ignore[attr-defined, typeddict-item] - ) - elif content.type == "function_result": - if tool_results is None: - tool_results = [] - tool_results.append(content) - - if len(message_contents) > 0: - if additional_messages is None: - additional_messages = [] - additional_messages.append( - AdditionalMessage( - role="assistant" if chat_message.role == "assistant" else "user", - content=message_contents, - ) - ) - - if additional_messages is not None: - run_options["additional_messages"] = additional_messages - - if len(instructions) > 0: - run_options["instructions"] = "".join(instructions) - - return run_options, tool_results - - def _prepare_tool_outputs_for_assistants( - self, - tool_results: list[Content] | None, - ) -> tuple[str | None, list[ToolOutput] | None]: - """Prepare function results for submission to the assistants API.""" - run_id: str | None = None - tool_outputs: list[ToolOutput] | None = None - - if tool_results: - for function_result_content in tool_results: - # When creating the FunctionCallContent, we created it with a CallId == [runId, callId]. - # We need to extract the run ID and ensure that the ToolOutput we send back to Azure - # is only the call ID. - run_and_call_ids: list[str] = json.loads(function_result_content.call_id) # type: ignore[arg-type] - - if ( - not run_and_call_ids - or len(run_and_call_ids) != 2 - or not run_and_call_ids[0] - or not run_and_call_ids[1] - or (run_id is not None and run_id != run_and_call_ids[0]) - ): - continue - - run_id = run_and_call_ids[0] - call_id = run_and_call_ids[1] - - if tool_outputs is None: - tool_outputs = [] - output = ( - function_result_content.result - if function_result_content.result is not None - else "No output received." - ) - tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output)) - - return run_id, tool_outputs - - def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None: - """Update the agent name in the chat client. - - Args: - agent_name: The new name for the agent. - description: The new description for the agent. - """ - # This is a no-op in the base class, but can be overridden by subclasses - # to update the agent name in the client. - if agent_name and not self.assistant_name: - self.assistant_name = agent_name - if description and not self.assistant_description: - self.assistant_description = description diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index bc7f2f3ba8..963888e45a 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -289,7 +289,7 @@ class RawOpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. api_key: API key. When not provided explicitly, the constructor reads ``OPENAI_API_KEY``. A callable API key is also supported. org_id: OpenAI organization ID. When not provided explicitly, the constructor reads @@ -331,8 +331,8 @@ class RawOpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_CHAT_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -361,7 +361,6 @@ class RawOpenAIChatClient( # type: ignore[misc] self, model: str | None = None, *, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, @@ -381,10 +380,8 @@ class RawOpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI, - or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure. - model_id: Deprecated alias for ``model``. + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI, + or ``AZURE_OPENAI_CHAT_MODEL`` and then ``AZURE_OPENAI_MODEL`` for Azure. api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -421,18 +418,12 @@ class RawOpenAIChatClient( # type: ignore[misc] 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` 3. Azure environment fallback - OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - settings, client, use_azure_client = load_openai_service_settings( model=model, api_key=api_key, @@ -446,13 +437,13 @@ class RawOpenAIChatClient( # type: ignore[misc] client=async_client, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - openai_model_fields=("responses_model", "model"), - azure_deployment_fields=("responses_deployment_name", "deployment_name"), + openai_model_fields=("chat_model", "model"), + azure_model_fields=("chat_model", "model"), responses_mode=True, ) self.client = client - self.model: str = settings.get("model") or settings.get("deployment_name") or "" + self.model: str = settings.get("model") or "" # Store configuration for serialization self.org_id = settings.get("org_id") @@ -1178,7 +1169,6 @@ class RawOpenAIChatClient( # type: ignore[misc] # translations between options and Responses API translations = { - "model_id": "model", # backward compat: accept model_id in options "allow_multiple_tool_calls": "parallel_tool_calls", "conversation_id": "previous_response_id", "max_tokens": "max_output_tokens", @@ -1235,7 +1225,7 @@ class RawOpenAIChatClient( # type: ignore[misc] def _check_model_presence(self, options: dict[str, Any]) -> None: """Check if the 'model' param is present, and if not raise a Error. - Since AzureAIClients use a different param for this, this method is overridden in those clients. + Subclasses can override this when they populate the model through a different option field. """ if not options.get("model"): if not self.model: @@ -1922,9 +1912,7 @@ class RawOpenAIChatClient( # type: ignore[misc] args["usage_details"] = usage_details if structured_response: args["value"] = structured_response - elif (response_format := options.get("response_format")) and isinstance(response_format, type): - # Only pass response_format to ChatResponse if it's a Pydantic model type, - # not a runtime JSON schema dict + elif response_format := options.get("response_format"): args["response_format"] = response_format # Set continuation_token when background operation is still in progress if response.status and response.status in ("in_progress", "queued"): @@ -2513,7 +2501,7 @@ class OpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. api_key: API key. When not provided explicitly, the constructor reads ``OPENAI_API_KEY``. A callable API key is also supported. org_id: OpenAI organization ID. When not provided explicitly, the constructor reads @@ -2559,8 +2547,8 @@ class OpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_CHAT_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -2612,9 +2600,9 @@ class OpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI - routing, or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI + routing, or ``AZURE_OPENAI_CHAT_MODEL`` and then + ``AZURE_OPENAI_MODEL`` for Azure routing. api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -2653,11 +2641,11 @@ class OpenAIChatClient( # type: ignore[misc] 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` 3. Azure environment fallback - OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. Examples: .. code-block:: python diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 4828014e5b..2da59e031e 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -114,7 +114,7 @@ class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseM Extends ChatOptions with options specific to OpenAI's Chat Completions API. Keys: - model_id: The model to use for the request, + model: The model to use for the request, translates to ``model`` in OpenAI API. temperature: Sampling temperature between 0 and 2. top_p: Nucleus sampling parameter. @@ -155,7 +155,6 @@ OpenAIChatCompletionOptionsT = TypeVar( ) OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", # backward compat: accept model_id in options "allow_multiple_tool_calls": "parallel_tool_calls", "max_tokens": "max_completion_tokens", } @@ -204,7 +203,7 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + reads ``OPENAI_CHAT_COMPLETION_MODEL`` and then ``OPENAI_MODEL``. api_key: API key. When not provided explicitly, the constructor reads ``OPENAI_API_KEY``. A callable API key is also supported. org_id: OpenAI organization ID. When not provided explicitly, the constructor reads @@ -246,8 +245,8 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_CHAT_COMPLETION_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -276,7 +275,6 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] self, model: str | None = None, *, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, @@ -296,10 +294,8 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, - or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. - model_id: Deprecated alias for ``model``. + reads ``OPENAI_CHAT_COMPLETION_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, + or ``AZURE_OPENAI_CHAT_COMPLETION_MODEL`` and then ``AZURE_OPENAI_MODEL`` for Azure routing. api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -336,18 +332,12 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` 3. Azure environment fallback - OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_COMPLETION_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_COMPLETION_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - settings, client, use_azure_client = load_openai_service_settings( model=model, api_key=api_key, @@ -361,12 +351,12 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] client=async_client, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - openai_model_fields=("chat_model", "model"), - azure_deployment_fields=("chat_deployment_name", "deployment_name"), + openai_model_fields=("chat_completion_model", "model"), + azure_model_fields=("chat_completion_model", "model"), ) self.client = client - self.model: str = settings.get("model") or settings.get("deployment_name") or "" + self.model: str = settings.get("model") or "" # Store configuration for serialization self.org_id = settings.get("org_id") @@ -1058,7 +1048,7 @@ class OpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + reads ``OPENAI_CHAT_COMPLETION_MODEL`` and then ``OPENAI_MODEL``. api_key: API key. When not provided explicitly, the constructor reads ``OPENAI_API_KEY``. A callable API key is also supported. org_id: OpenAI organization ID. When not provided explicitly, the constructor reads @@ -1098,8 +1088,8 @@ class OpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_CHAT_COMPLETION_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -1145,9 +1135,9 @@ class OpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, - or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + reads ``OPENAI_CHAT_COMPLETION_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, + or ``AZURE_OPENAI_CHAT_COMPLETION_MODEL`` and then + ``AZURE_OPENAI_MODEL`` for Azure routing. api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -1183,11 +1173,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` 3. Azure environment fallback - OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_COMPLETION_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_COMPLETION_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. Examples: .. code-block:: python diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index 6a637e29da..b11304dd27 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -123,8 +123,8 @@ class RawOpenAIEmbeddingClient( Keyword Args: model: Embedding deployment name. When not provided, the constructor reads - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + ``AZURE_OPENAI_EMBEDDING_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -150,7 +150,6 @@ class RawOpenAIEmbeddingClient( self, *, model: str | None = None, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, @@ -168,9 +167,8 @@ class RawOpenAIEmbeddingClient( Keyword Args: model: Embedding model or Azure OpenAI deployment name. When not provided, the constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` - for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` - and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. - model_id: Deprecated alias for ``model``. + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_MODEL`` + and then ``AZURE_OPENAI_MODEL``. api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -207,15 +205,9 @@ class RawOpenAIEmbeddingClient( OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - settings, client, use_azure_client = load_openai_service_settings( model=model, api_key=api_key, @@ -230,11 +222,11 @@ class RawOpenAIEmbeddingClient( env_file_path=env_file_path, env_file_encoding=env_file_encoding, openai_model_fields=("embedding_model", "model"), - azure_deployment_fields=("embedding_deployment_name", "deployment_name"), + azure_model_fields=("embedding_model", "model"), ) self.client = client - resolved_model = settings.get("model") or settings.get("deployment_name") + resolved_model = settings.get("model") self.model: str | None = resolved_model.strip() if isinstance(resolved_model, str) and resolved_model else None # Store configuration for serialization @@ -279,8 +271,7 @@ class RawOpenAIEmbeddingClient( return GeneratedEmbeddings([], options=options) # type: ignore opts: dict[str, Any] = options or {} # type: ignore - # backward compat: accept model_id in options - model = opts.get("model") or opts.get("model_id") or self.model + model = opts.get("model") or self.model if not model: raise ValueError("model is required") @@ -385,8 +376,8 @@ class OpenAIEmbeddingClient( Keyword Args: model: Embedding deployment name. When not provided, the constructor reads - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + ``AZURE_OPENAI_EMBEDDING_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -429,8 +420,8 @@ class OpenAIEmbeddingClient( Keyword Args: model: Embedding model or Azure OpenAI deployment name. When not provided, the constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` - for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` - and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_MODEL`` + and then ``AZURE_OPENAI_MODEL``. api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -467,8 +458,8 @@ class OpenAIEmbeddingClient( OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. Examples: .. code-block:: python diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index feb30cc7d5..f1d0728f61 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -2,17 +2,13 @@ from __future__ import annotations -import logging import sys -from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from copy import copy -from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, cast +from typing import TYPE_CHECKING, Any, Literal, Union -import openai -from agent_framework._serialization import SerializationMixin from agent_framework._settings import SecretString, load_settings -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent -from agent_framework._tools import FunctionTool +from agent_framework._telemetry import APP_INFO, prepend_agent_framework_to_user_agent from agent_framework.exceptions import SettingNotFoundError from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore from openai.types import Completion @@ -21,7 +17,6 @@ from openai.types.chat import ChatCompletion, ChatCompletionChunk from openai.types.images_response import ImagesResponse from openai.types.responses.response import Response from openai.types.responses.response_stream_event import ResponseStreamEvent -from packaging.version import parse if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover @@ -35,8 +30,6 @@ if TYPE_CHECKING: AzureCredentialTypes = TokenCredential | AsyncTokenCredential -logger: logging.Logger = logging.getLogger("agent_framework.openai") - AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # noqa: S105 # nosec B105 @@ -56,29 +49,6 @@ RESPONSE_TYPE = Union[ AzureTokenProvider = Callable[[], str | Awaitable[str]] -def _check_openai_version_for_callable_api_key() -> None: - """Check if OpenAI version supports callable API keys. - - Callable API keys require OpenAI >= 1.106.0. - If the version is too old, raise a ValueError with helpful message. - """ - try: - current_version = parse(openai.__version__) - min_required_version = parse("1.106.0") - - if current_version < min_required_version: - raise ValueError( - f"Callable API keys require OpenAI SDK >= 1.106.0, but you have {openai.__version__}. " - f"Please upgrade with 'pip install openai>=1.106.0' or provide a string API key instead. " - f"Note: If you're using mem0ai, you may need to upgrade to mem0ai>=1.0.0 " - f"to allow newer OpenAI versions." - ) - except ValueError: - raise # Re-raise our own exception - except Exception as e: - logger.warning(f"Could not check OpenAI version for callable API key support: {e}") - - class OpenAISettings(TypedDict, total=False): """OpenAI environment settings. @@ -97,10 +67,10 @@ class OpenAISettings(TypedDict, total=False): Can be set via environment variable OPENAI_MODEL. embedding_model: The OpenAI embedding model to use, for example, text-embedding-3-small. Can be set via environment variable OPENAI_EMBEDDING_MODEL. - chat_model: The OpenAI chat-completions model to prefer before OPENAI_MODEL. + chat_model: The OpenAIChatClient model to prefer before OPENAI_MODEL. Can be set via environment variable OPENAI_CHAT_MODEL. - responses_model: The OpenAI responses model to prefer before OPENAI_MODEL. - Can be set via environment variable OPENAI_RESPONSES_MODEL. + chat_completion_model: The OpenAIChatCompletionClient model to prefer before OPENAI_MODEL. + Can be set via environment variable OPENAI_CHAT_COMPLETION_MODEL. Examples: .. code-block:: python @@ -125,7 +95,7 @@ class OpenAISettings(TypedDict, total=False): model: str | None embedding_model: str | None chat_model: str | None - responses_model: str | None + chat_completion_model: str | None class AzureOpenAISettings(TypedDict, total=False): @@ -134,36 +104,33 @@ class AzureOpenAISettings(TypedDict, total=False): endpoint: str | None base_url: str | None api_key: SecretString | None - deployment_name: str | None - embedding_deployment_name: str | None - chat_deployment_name: str | None - responses_deployment_name: str | None + model: str | None + embedding_model: str | None + chat_model: str | None + chat_completion_model: str | None api_version: str | None -OpenAIModelSettingName = Literal["model", "embedding_model", "chat_model", "responses_model"] -AzureDeploymentSettingName = Literal[ - "deployment_name", "embedding_deployment_name", "chat_deployment_name", "responses_deployment_name" -] +OpenAIModelSettingName = Literal["model", "embedding_model", "chat_model", "chat_completion_model"] OPENAI_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { "model": "OPENAI_MODEL", "embedding_model": "OPENAI_EMBEDDING_MODEL", "chat_model": "OPENAI_CHAT_MODEL", - "responses_model": "OPENAI_RESPONSES_MODEL", + "chat_completion_model": "OPENAI_CHAT_COMPLETION_MODEL", } -AZURE_DEPLOYMENT_ENV_VARS: dict[AzureDeploymentSettingName, str] = { - "deployment_name": "AZURE_OPENAI_DEPLOYMENT_NAME", - "embedding_deployment_name": "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "chat_deployment_name": "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", - "responses_deployment_name": "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", +AZURE_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { + "model": "AZURE_OPENAI_MODEL", + "embedding_model": "AZURE_OPENAI_EMBEDDING_MODEL", + "chat_model": "AZURE_OPENAI_CHAT_MODEL", + "chat_completion_model": "AZURE_OPENAI_CHAT_COMPLETION_MODEL", } def _resolve_named_setting( settings: Mapping[str, Any], - fields: Sequence[OpenAIModelSettingName | AzureDeploymentSettingName], + fields: Sequence[OpenAIModelSettingName], ) -> str | None: """Return the first populated value from ``fields``.""" for field in fields: @@ -193,10 +160,10 @@ def load_openai_service_settings( env_file_path: str | None, env_file_encoding: str | None, openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",), - azure_deployment_fields: Sequence[AzureDeploymentSettingName] = ("deployment_name",), + azure_model_fields: Sequence[OpenAIModelSettingName] = ("model",), responses_mode: bool = False, ) -> tuple[dict[str, Any], AsyncOpenAI, bool]: - """Load OpenAI settings, including Azure OpenAI aliases. + """Load OpenAI settings, including Azure OpenAI model aliases. The generic OpenAI clients primarily read from ``OPENAI_*`` variables. Azure-specific environment variables are used only when an explicit Azure signal is present @@ -265,20 +232,18 @@ def load_openai_service_settings( env_file_encoding=env_file_encoding, ) if model is not None: - azure_settings[azure_deployment_fields[0]] = model + azure_settings[azure_model_fields[0]] = model client_args = {} - resolved_azure_deployment = _resolve_named_setting(azure_settings, azure_deployment_fields) - if resolved_azure_deployment is None and client: + resolved_azure_model = _resolve_named_setting(azure_settings, azure_model_fields) + if resolved_azure_model is None and client: azure_deployment = getattr(client, "_azure_deployment", None) if isinstance(azure_deployment, str) and azure_deployment: - resolved_azure_deployment = azure_deployment - if resolved_azure_deployment: - azure_settings["deployment_name"] = resolved_azure_deployment - client_args["azure_deployment"] = resolved_azure_deployment + resolved_azure_model = azure_deployment + if resolved_azure_model: + azure_settings["model"] = resolved_azure_model + client_args["azure_deployment"] = resolved_azure_model else: - deployment_env_guidance = _join_env_names([ - AZURE_DEPLOYMENT_ENV_VARS[field] for field in azure_deployment_fields - ]) + deployment_env_guidance = _join_env_names([AZURE_MODEL_ENV_VARS[field] for field in azure_model_fields]) has_azure_configuration = ( client is not None or azure_settings.get("endpoint") is not None @@ -291,7 +256,7 @@ def load_openai_service_settings( "'AZURE_OPENAI_BASE_URL'." ) raise SettingNotFoundError( - "Azure OpenAI client requires a deployment name, which can be provided via the 'model' parameter, " + "Azure OpenAI client requires a model, which can be provided via the 'model' parameter, " f"or the {deployment_env_guidance} environment variable." ) if client: @@ -374,256 +339,4 @@ def get_api_key( if isinstance(api_key, SecretString): return api_key.get_secret_value() - # Check version compatibility for callable API keys - if callable(api_key): - _check_openai_version_for_callable_api_key() - return api_key # Pass callable, string, or None directly to OpenAI SDK - - -class OpenAIBase(SerializationMixin): - """Base class for OpenAI Clients. - - .. deprecated:: - ``OpenAIBase`` is deprecated and only used by ``OpenAIAssistantsClient`` - and ``AzureOpenAIAssistantsClient``. New clients should manage ``client`` - and ``model`` directly in their own ``__init__``. - """ - - INJECTABLE: ClassVar[set[str]] = {"client"} - - def __init__( - self, *, model: str | None = None, model_id: str | None = None, client: AsyncOpenAI | None = None, **kwargs: Any - ) -> None: - """Initialize OpenAIBase. - - Keyword Args: - client: The AsyncOpenAI client instance. - model: The AI model to use. - model_id: Deprecated alias for ``model``. - **kwargs: Additional keyword arguments. - """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - self.client = client - self.model: str | None = None - if model: - self.model = model.strip() - - # Call super().__init__() to continue MRO chain (e.g., RawChatClient) - # Extract known kwargs that belong to other base classes - additional_properties = kwargs.pop("additional_properties", None) - middleware = kwargs.pop("middleware", None) - instruction_role = kwargs.pop("instruction_role", None) - function_invocation_configuration = kwargs.pop("function_invocation_configuration", None) - - # Build super().__init__() args - super_kwargs = {} - if additional_properties is not None: - super_kwargs["additional_properties"] = additional_properties - if middleware is not None: - super_kwargs["middleware"] = middleware - if function_invocation_configuration is not None: - super_kwargs["function_invocation_configuration"] = function_invocation_configuration - - # Call super().__init__() with filtered kwargs - super().__init__(**super_kwargs) - - # Store instruction_role and any remaining kwargs as instance attributes - if instruction_role is not None: - self.instruction_role = instruction_role - for key, value in kwargs.items(): - setattr(self, key, value) - - async def _initialize_client(self) -> None: - """Initialize OpenAI client asynchronously. - - Override in subclasses to initialize the OpenAI client asynchronously. - """ - pass - - async def _ensure_client(self) -> AsyncOpenAI: - """Ensure OpenAI client is initialized.""" - await self._initialize_client() - if self.client is None: - raise RuntimeError("OpenAI client is not initialized") - - return self.client - - def _get_api_key( - self, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None - ) -> str | Callable[[], str | Awaitable[str]] | None: - """Get the appropriate API key value for client initialization. - - Args: - api_key: The API key parameter which can be a string, SecretString, callable, or None. - - Returns: - For callable API keys: returns the callable directly. - For SecretString/string/None API keys: returns as-is (SecretString is a str subclass). - """ - if isinstance(api_key, SecretString): - return api_key.get_secret_value() - - # Check version compatibility for callable API keys - if callable(api_key): - _check_openai_version_for_callable_api_key() - - return api_key # Pass callable, string, or None directly to OpenAI SDK - - -class OpenAIConfigMixin(OpenAIBase): - """Internal class for configuring a connection to an OpenAI service. - - .. deprecated:: - ``OpenAIConfigMixin`` is deprecated and only used by ``OpenAIAssistantsClient`` - and ``AzureOpenAIAssistantsClient``. New clients handle configuration - directly in their own ``__init__``. - """ - - OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] - - def __init__( - self, - model: str, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - default_headers: Mapping[str, str] | None = None, - client: AsyncOpenAI | None = None, - instruction_role: str | None = None, - base_url: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a client for OpenAI services. - - This constructor sets up a client to interact with OpenAI's API, allowing for - different types of AI model interactions, like chat or text completion. - - Args: - model: OpenAI model identifier. Must be non-empty. - Default to a preset value. - api_key: OpenAI API key for authentication, or a callable that returns an API key. - Must be non-empty. (Optional) - org_id: OpenAI organization ID. This is optional - unless the account belongs to multiple organizations. - default_headers: Default headers - for HTTP requests. (Optional) - client: An existing OpenAI client, optional. - instruction_role: The role to use for 'instruction' - messages, for example, summarization prompts could use `developer` or `system`. (Optional) - base_url: The optional base URL to use. If provided will override the standard value for a OpenAI connector. - Will not be used when supplying a custom client. - kwargs: Additional keyword arguments. - - """ - # Merge APP_INFO into the headers if it exists - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - # Handle callable API key using base class method - api_key_value = self._get_api_key(api_key) - - if not client: - if not api_key: - raise ValueError("Please provide an api_key") - args: dict[str, Any] = {"api_key": api_key_value, "default_headers": merged_headers} - if org_id: - args["organization"] = org_id - if base_url: - args["base_url"] = base_url - client = AsyncOpenAI(**args) - - # Store configuration as instance attributes for serialization - self.org_id = org_id - self.base_url = str(base_url) - # Store default_headers but filter out USER_AGENT_KEY for serialization - if default_headers: - self.default_headers: dict[str, Any] | None = { - k: v for k, v in default_headers.items() if k != USER_AGENT_KEY - } - else: - self.default_headers = None - - args = { - "model": model, - "client": client, - } - if instruction_role: - args["instruction_role"] = instruction_role - - # Ensure additional_properties and middleware are passed through kwargs to RawChatClient - # These are consumed by RawChatClient.__init__ via kwargs - super().__init__(**args, **kwargs) - - -def to_assistant_tools( - tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, -) -> list[dict[str, Any]]: - """Convert Agent Framework tools to OpenAI Assistants API format. - - Handles FunctionTool instances and dict-based tools from static factory methods. - - Args: - tools: Sequence of Agent Framework tools. - - Returns: - List of tool definitions for OpenAI Assistants API. - """ - if not tools: - return [] - - tool_definitions: list[dict[str, Any]] = [] - - for tool in tools: - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) - elif isinstance(tool, MutableMapping): - # Pass through dict-based tools directly (from static factory methods) - tool_definitions.append(dict(tool)) - - return tool_definitions - - -def from_assistant_tools( - assistant_tools: list[Any] | None, -) -> list[dict[str, Any]]: - """Convert OpenAI Assistant tools to dict-based format. - - This converts hosted tools (code_interpreter, file_search) from an OpenAI - Assistant definition back to dict-based tool definitions. - - Note: Function tools are skipped - user must provide implementations separately. - - Args: - assistant_tools: Tools from OpenAI Assistant object (assistant.tools). - - Returns: - List of dict-based tool definitions for hosted tools. - """ - if not assistant_tools: - return [] - - tools: list[dict[str, Any]] = [] - - for tool in assistant_tools: - if hasattr(tool, "type"): - tool_type = tool.type - elif isinstance(tool, Mapping): - typed_tool = cast(Mapping[str, Any], tool) - tool_type_value: Any = typed_tool.get("type") - tool_type = tool_type_value if isinstance(tool_type_value, str) else None - else: - tool_type = None - - if tool_type == "code_interpreter": - tools.append({"type": "code_interpreter"}) - elif tool_type == "file_search": - tools.append({"type": "file_search"}) - # Skip function tools - user must provide implementations - - return tools diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index eb31ca5956..2e2cef12ad 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "agent-framework-openai" -description = "OpenAI integration for Microsoft Agent Framework." +description = "OpenAI integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc6" +version = "1.0.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -23,9 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "openai>=1.99.0,<3", - "packaging>=24.1,<25", ] [tool.uv] diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index 34ea878a19..34c81952e3 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -43,22 +43,17 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "OPENAI_ORG_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", + "OPENAI_CHAT_COMPLETION_MODEL", "OPENAI_CHAT_MODEL", - "OPENAI_RESPONSES_MODEL", - "OPENAI_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_IMAGE_MODEL_ID", - "OPENAI_AUDIO_TO_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_AUDIO_MODEL_ID", - "OPENAI_REALTIME_MODEL_ID", "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "AZURE_OPENAI_DEPLOYMENT_NAME", + "AZURE_OPENAI_CHAT_COMPLETION_MODEL", + "AZURE_OPENAI_CHAT_MODEL", + "AZURE_OPENAI_EMBEDDING_MODEL", + "AZURE_OPENAI_MODEL", "AZURE_OPENAI_API_VERSION", ], ) @@ -66,13 +61,8 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # env_vars = { "OPENAI_API_KEY": "test-dummy-key", "OPENAI_ORG_ID": "test_org_id", - "OPENAI_MODEL": "test_model_id", - "OPENAI_EMBEDDING_MODEL": "test_embedding_model_id", - "OPENAI_TEXT_MODEL_ID": "test_text_model_id", - "OPENAI_TEXT_TO_IMAGE_MODEL_ID": "test_text_to_image_model_id", - "OPENAI_AUDIO_TO_TEXT_MODEL_ID": "test_audio_to_text_model_id", - "OPENAI_TEXT_TO_AUDIO_MODEL_ID": "test_text_to_audio_model_id", - "OPENAI_REALTIME_MODEL_ID": "test_realtime_model_id", + "OPENAI_MODEL": "test_model", + "OPENAI_EMBEDDING_MODEL": "test_embedding_model", } env_vars.update(override_env_param_dict) # type: ignore @@ -102,32 +92,27 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "OPENAI_ORG_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", + "OPENAI_CHAT_COMPLETION_MODEL", "OPENAI_CHAT_MODEL", - "OPENAI_RESPONSES_MODEL", - "OPENAI_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_IMAGE_MODEL_ID", - "OPENAI_AUDIO_TO_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_AUDIO_MODEL_ID", - "OPENAI_REALTIME_MODEL_ID", "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "AZURE_OPENAI_DEPLOYMENT_NAME", + "AZURE_OPENAI_CHAT_COMPLETION_MODEL", + "AZURE_OPENAI_CHAT_MODEL", + "AZURE_OPENAI_EMBEDDING_MODEL", + "AZURE_OPENAI_MODEL", "AZURE_OPENAI_API_VERSION", ], ) env_vars = { "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.openai.azure.com", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_responses_deployment", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment", - "AZURE_OPENAI_DEPLOYMENT_NAME": "test_deployment", + "AZURE_OPENAI_CHAT_COMPLETION_MODEL": "test_chat_deployment", + "AZURE_OPENAI_CHAT_MODEL": "test_responses_deployment", + "AZURE_OPENAI_EMBEDDING_MODEL": "test_embedding_deployment", + "AZURE_OPENAI_MODEL": "test_deployment", "AZURE_OPENAI_API_KEY": "test_api_key", "AZURE_OPENAI_API_VERSION": "2024-12-01-preview", } diff --git a/python/packages/openai/tests/openai/test_assistant_provider.py b/python/packages/openai/tests/openai/test_assistant_provider.py deleted file mode 100644 index df811f6c37..0000000000 --- a/python/packages/openai/tests/openai/test_assistant_provider.py +++ /dev/null @@ -1,751 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock - -import pytest -from agent_framework import Agent, normalize_tools, tool -from openai.types.beta.assistant import Assistant -from pydantic import BaseModel, Field - -from agent_framework_openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from agent_framework_openai._shared import from_assistant_tools, to_assistant_tools - -# region Test Helpers - - -def create_mock_assistant( - assistant_id: str = "asst_test123", - name: str = "TestAssistant", - model: str = "gpt-4", - instructions: str | None = "You are a helpful assistant.", - description: str | None = None, - tools: list[Any] | None = None, -) -> Assistant: - """Create a mock Assistant object.""" - mock = MagicMock(spec=Assistant) - mock.id = assistant_id - mock.name = name - mock.model = model - mock.instructions = instructions - mock.description = description - mock.tools = tools or [] - return mock - - -def create_function_tool(name: str, description: str = "A test function") -> MagicMock: - """Create a mock FunctionTool.""" - mock = MagicMock() - mock.type = "function" - mock.function = MagicMock() - mock.function.name = name - mock.function.description = description - return mock - - -def create_code_interpreter_tool() -> MagicMock: - """Create a mock CodeInterpreterTool.""" - mock = MagicMock() - mock.type = "code_interpreter" - return mock - - -def create_file_search_tool() -> MagicMock: - """Create a mock FileSearchTool.""" - mock = MagicMock() - mock.type = "file_search" - return mock - - -@pytest.fixture -def mock_async_openai() -> MagicMock: - """Mock AsyncOpenAI client.""" - mock_client = MagicMock() - - # Mock beta.assistants - mock_client.beta.assistants.create = AsyncMock( - return_value=create_mock_assistant(assistant_id="asst_created123", name="CreatedAssistant") - ) - mock_client.beta.assistants.retrieve = AsyncMock( - return_value=create_mock_assistant(assistant_id="asst_retrieved123", name="RetrievedAssistant") - ) - mock_client.beta.assistants.delete = AsyncMock() - - # Mock close method - mock_client.close = AsyncMock() - - return mock_client - - -# Test function for tool validation -def get_weather(location: Annotated[str, Field(description="The location")]) -> str: - """Get the weather for a location.""" - return f"Weather in {location}: sunny" - - -def search_database(query: Annotated[str, Field(description="Search query")]) -> str: - """Search the database.""" - return f"Results for: {query}" - - -# Pydantic model for structured output tests -class WeatherResponse(BaseModel): - location: str - temperature: float - conditions: str - - -# endregion - -# region Initialization Tests - - -class TestOpenAIAssistantProviderInit: - """Tests for provider initialization.""" - - def test_init_with_client(self, mock_async_openai: MagicMock) -> None: - """Test initialization with existing AsyncOpenAI client.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - assert provider._client is mock_async_openai # type: ignore[reportPrivateUsage] - assert provider._should_close_client is False # type: ignore[reportPrivateUsage] - - def test_init_without_client_creates_one(self, openai_unit_test_env: dict[str, str]) -> None: - """Test initialization creates client from settings.""" - provider = OpenAIAssistantProvider() - - assert provider._client is not None # type: ignore[reportPrivateUsage] - assert provider._should_close_client is True # type: ignore[reportPrivateUsage] - - def test_init_with_api_key(self) -> None: - """Test initialization with explicit API key.""" - provider = OpenAIAssistantProvider(api_key="sk-test-key") - - assert provider._client is not None # type: ignore[reportPrivateUsage] - assert provider._should_close_client is True # type: ignore[reportPrivateUsage] - - def test_init_fails_without_api_key(self) -> None: - """Test initialization fails without API key when settings return None.""" - from unittest.mock import patch - - # Mock load_settings to return a dict with None for api_key - with patch("agent_framework_openai._assistant_provider.load_settings") as mock_load: - mock_load.return_value = { - "api_key": None, - "org_id": None, - "base_url": None, - "model": None, - } - - with pytest.raises(ValueError) as exc_info: - OpenAIAssistantProvider() - - assert "API key is required" in str(exc_info.value) - - def test_init_with_org_id_and_base_url(self) -> None: - """Test initialization with organization ID and base URL.""" - provider = OpenAIAssistantProvider( - api_key="sk-test-key", - org_id="org-123", - base_url="https://custom.openai.com", - ) - - assert provider._client is not None # type: ignore[reportPrivateUsage] - - -class TestOpenAIAssistantProviderContextManager: - """Tests for async context manager.""" - - async def test_context_manager_enter_exit(self, mock_async_openai: MagicMock) -> None: - """Test async context manager entry and exit.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - async with provider as p: - assert p is provider - - async def test_context_manager_closes_owned_client(self, openai_unit_test_env: dict[str, str]) -> None: - """Test that owned client is closed on exit.""" - provider = OpenAIAssistantProvider() - client = provider._client # type: ignore[reportPrivateUsage] - assert client is not None - client.close = AsyncMock() - - async with provider: - pass - - client.close.assert_called_once() - - async def test_context_manager_does_not_close_external_client(self, mock_async_openai: MagicMock) -> None: - """Test that external client is not closed on exit.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - async with provider: - pass - - mock_async_openai.close.assert_not_called() - - -# endregion - -# region create_agent Tests - - -class TestOpenAIAssistantProviderCreateAgent: - """Tests for create_agent method.""" - - async def test_create_agent_basic(self, mock_async_openai: MagicMock) -> None: - """Test basic assistant creation.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.create_agent( - name="TestAgent", - model="gpt-4", - instructions="You are helpful.", - ) - - assert isinstance(agent, Agent) - assert agent.name == "CreatedAssistant" - mock_async_openai.beta.assistants.create.assert_called_once() - - # Verify create was called with correct parameters - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["name"] == "TestAgent" - assert call_kwargs["model"] == "gpt-4" - assert call_kwargs["instructions"] == "You are helpful." - - async def test_create_agent_with_description(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with description.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="TestAgent", - model="gpt-4", - description="A test agent description", - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["description"] == "A test agent description" - - async def test_create_agent_with_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with function tools.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.create_agent( - name="WeatherAgent", - model="gpt-4", - tools=[get_weather], - ) - - assert isinstance(agent, Agent) - - # Verify tools were passed to create - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert "tools" in call_kwargs - assert len(call_kwargs["tools"]) == 1 - assert call_kwargs["tools"][0]["type"] == "function" - assert call_kwargs["tools"][0]["function"]["name"] == "get_weather" - - async def test_create_agent_with_tool(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with FunctionTool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - @tool - def my_function(x: int) -> int: - """Double a number.""" - return x * 2 - - await provider.create_agent( - name="TestAgent", - model="gpt-4", - tools=[my_function], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["tools"][0]["function"]["name"] == "my_function" - - async def test_create_agent_with_code_interpreter(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with code interpreter.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="CodeAgent", - model="gpt-4", - tools=[OpenAIAssistantsClient.get_code_interpreter_tool()], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert {"type": "code_interpreter"} in call_kwargs["tools"] - - async def test_create_agent_with_file_search(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with file search.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="SearchAgent", - model="gpt-4", - tools=[OpenAIAssistantsClient.get_file_search_tool()], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert any(t["type"] == "file_search" for t in call_kwargs["tools"]) - - async def test_create_agent_with_file_search_max_results(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with file search and max_results.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="SearchAgent", - model="gpt-4", - tools=[OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - file_search_tool = next(t for t in call_kwargs["tools"] if t["type"] == "file_search") - assert file_search_tool.get("file_search", {}).get("max_num_results") == 10 - - async def test_create_agent_with_mixed_tools(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with multiple tool types.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="MultiToolAgent", - model="gpt-4", - tools=[ - get_weather, - OpenAIAssistantsClient.get_code_interpreter_tool(), - OpenAIAssistantsClient.get_file_search_tool(), - ], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert len(call_kwargs["tools"]) == 3 - - async def test_create_agent_with_metadata(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with metadata.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="TestAgent", - model="gpt-4", - metadata={"env": "test", "version": "1.0"}, - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["metadata"] == {"env": "test", "version": "1.0"} - - async def test_create_agent_with_response_format_pydantic(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with Pydantic response format via default_options.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="StructuredAgent", - model="gpt-4", - default_options={"response_format": WeatherResponse}, - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["response_format"]["type"] == "json_schema" - assert call_kwargs["response_format"]["json_schema"]["name"] == "WeatherResponse" - - async def test_create_agent_returns_chat_agent(self, mock_async_openai: MagicMock) -> None: - """Test that create_agent returns a Agent instance.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.create_agent( - name="TestAgent", - model="gpt-4", - ) - - assert isinstance(agent, Agent) - - -# endregion - -# region get_agent Tests - - -class TestOpenAIAssistantProviderGetAgent: - """Tests for get_agent method.""" - - async def test_get_agent_basic(self, mock_async_openai: MagicMock) -> None: - """Test retrieving an existing assistant.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent(assistant_id="asst_123") - - assert isinstance(agent, Agent) - mock_async_openai.beta.assistants.retrieve.assert_called_once_with("asst_123") - - async def test_get_agent_with_instructions_override(self, mock_async_openai: MagicMock) -> None: - """Test retrieving assistant with instruction override.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent( - assistant_id="asst_123", - instructions="Custom instructions", - ) - - # Agent should be created successfully with the custom instructions - assert isinstance(agent, Agent) - assert agent.id == "asst_retrieved123" - - async def test_get_agent_with_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test retrieving assistant with function tools provided.""" - # Setup assistant with function tool - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent( - assistant_id="asst_123", - tools=[get_weather], - ) - - assert isinstance(agent, Agent) - - async def test_get_agent_validates_missing_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test that missing function tools raise ValueError.""" - # Setup assistant with function tool - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - with pytest.raises(ValueError) as exc_info: - await provider.get_agent(assistant_id="asst_123") - - assert "get_weather" in str(exc_info.value) - assert "no implementation was provided" in str(exc_info.value) - - async def test_get_agent_validates_multiple_missing_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test validation with multiple missing function tools.""" - assistant = create_mock_assistant( - tools=[create_function_tool("get_weather"), create_function_tool("search_database")] - ) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - with pytest.raises(ValueError) as exc_info: - await provider.get_agent(assistant_id="asst_123") - - error_msg = str(exc_info.value) - assert "get_weather" in error_msg or "search_database" in error_msg - - async def test_get_agent_merges_hosted_tools(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools are automatically included.""" - assistant = create_mock_assistant(tools=[create_code_interpreter_tool(), create_file_search_tool()]) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent(assistant_id="asst_123") - - # Hosted tools should be merged automatically - assert isinstance(agent, Agent) - - -# endregion - -# region as_agent Tests - - -class TestOpenAIAssistantProviderAsAgent: - """Tests for as_agent method.""" - - def test_as_agent_no_http_call(self, mock_async_openai: MagicMock) -> None: - """Test that as_agent doesn't make HTTP calls.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant() - - agent = provider.as_agent(assistant) - - assert isinstance(agent, Agent) - # Verify no HTTP calls were made - mock_async_openai.beta.assistants.create.assert_not_called() - mock_async_openai.beta.assistants.retrieve.assert_not_called() - - def test_as_agent_wraps_assistant(self, mock_async_openai: MagicMock) -> None: - """Test wrapping an SDK Assistant object.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant( - assistant_id="asst_wrap123", - name="WrappedAssistant", - instructions="Original instructions", - ) - - agent = provider.as_agent(assistant) - - assert agent.id == "asst_wrap123" - assert agent.name == "WrappedAssistant" - # Instructions are passed to ChatOptions, not exposed as attribute - assert isinstance(agent, Agent) - - def test_as_agent_with_instructions_override(self, mock_async_openai: MagicMock) -> None: - """Test as_agent with instruction override.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(instructions="Original") - - agent = provider.as_agent(assistant, instructions="Override") - - # Agent should be created successfully with override instructions - assert isinstance(agent, Agent) - - def test_as_agent_validates_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test that missing function tools raise ValueError.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - - with pytest.raises(ValueError) as exc_info: - provider.as_agent(assistant) - - assert "get_weather" in str(exc_info.value) - - def test_as_agent_with_function_tools_provided(self, mock_async_openai: MagicMock) -> None: - """Test as_agent with function tools provided.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - - agent = provider.as_agent(assistant, tools=[get_weather]) - - assert isinstance(agent, Agent) - - def test_as_agent_merges_hosted_tools(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools are merged automatically.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_code_interpreter_tool()]) - - agent = provider.as_agent(assistant) - - assert isinstance(agent, Agent) - - def test_as_agent_hosted_tools_not_required(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools don't require user implementations.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_code_interpreter_tool(), create_file_search_tool()]) - - # Should not raise - hosted tools don't need implementations - agent = provider.as_agent(assistant) - - assert isinstance(agent, Agent) - - -# endregion - -# region Tool Conversion Tests - - -class TestToolConversion: - """Tests for tool conversion utilities (shared functions).""" - - def test_to_assistant_tools_tool(self) -> None: - """Test FunctionTool conversion to API format.""" - - @tool - def test_func(x: int) -> int: - """Test function.""" - return x - - # Normalize tools first, then convert - normalized = normalize_tools([test_func]) - api_tools = to_assistant_tools(normalized) - - assert len(api_tools) == 1 - assert api_tools[0]["type"] == "function" - assert api_tools[0]["function"]["name"] == "test_func" - - def test_to_assistant_tools_callable(self) -> None: - """Test raw callable conversion via normalize_tools.""" - # normalize_tools converts callables to FunctionTool - normalized = normalize_tools([get_weather]) - api_tools = to_assistant_tools(normalized) - - assert len(api_tools) == 1 - assert api_tools[0]["type"] == "function" - assert api_tools[0]["function"]["name"] == "get_weather" - - def test_to_assistant_tools_code_interpreter(self) -> None: - """Test code_interpreter tool dict conversion.""" - api_tools = to_assistant_tools([OpenAIAssistantsClient.get_code_interpreter_tool()]) - - assert len(api_tools) == 1 - assert api_tools[0] == {"type": "code_interpreter"} - - def test_to_assistant_tools_file_search(self) -> None: - """Test file_search tool dict conversion.""" - api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool()]) - - assert len(api_tools) == 1 - assert api_tools[0]["type"] == "file_search" - - def test_to_assistant_tools_file_search_with_max_results(self) -> None: - """Test file_search tool with max_results conversion.""" - api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool(max_num_results=5)]) - - assert api_tools[0]["file_search"]["max_num_results"] == 5 - - def test_to_assistant_tools_dict(self) -> None: - """Test raw dict tool passthrough.""" - raw_tool = {"type": "function", "function": {"name": "custom", "description": "Custom tool"}} - - api_tools = to_assistant_tools([raw_tool]) - - assert len(api_tools) == 1 - assert api_tools[0] == raw_tool - - def test_to_assistant_tools_empty(self) -> None: - """Test conversion with no tools.""" - api_tools = to_assistant_tools(None) - - assert api_tools == [] - - def test_from_assistant_tools_code_interpreter(self) -> None: - """Test converting code_interpreter tool from OpenAI format.""" - assistant_tools = [create_code_interpreter_tool()] - - tools = from_assistant_tools(assistant_tools) - - assert len(tools) == 1 - assert tools[0] == {"type": "code_interpreter"} - - def test_from_assistant_tools_file_search(self) -> None: - """Test converting file_search tool from OpenAI format.""" - assistant_tools = [create_file_search_tool()] - - tools = from_assistant_tools(assistant_tools) - - assert len(tools) == 1 - assert tools[0] == {"type": "file_search"} - - def test_from_assistant_tools_function_skipped(self) -> None: - """Test that function tools are skipped (no implementations).""" - assistant_tools = [create_function_tool("test_func")] - - tools = from_assistant_tools(assistant_tools) - - assert len(tools) == 0 # Function tools are skipped - - def test_from_assistant_tools_empty(self) -> None: - """Test conversion with no tools.""" - tools = from_assistant_tools(None) - - assert tools == [] - - -# endregion - -# region Tool Validation Tests - - -class TestToolValidation: - """Tests for tool validation.""" - - def test_validate_missing_function_tool_raises(self, mock_async_openai: MagicMock) -> None: - """Test that missing function tools raise ValueError.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_function_tool("my_function")] - - with pytest.raises(ValueError) as exc_info: - provider._validate_function_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert "my_function" in str(exc_info.value) - - def test_validate_all_tools_provided_passes(self, mock_async_openai: MagicMock) -> None: - """Test that validation passes when all tools provided.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_function_tool("get_weather")] - - # Should not raise - provider._validate_function_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage] - - def test_validate_hosted_tools_not_required(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools don't require implementations.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool(), create_file_search_tool()] - - # Should not raise - provider._validate_function_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - def test_validate_with_tool(self, mock_async_openai: MagicMock) -> None: - """Test validation with FunctionTool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_function_tool("get_weather")] - - wrapped = tool(get_weather) - - # Should not raise - provider._validate_function_tools(assistant_tools, [wrapped]) # type: ignore[reportPrivateUsage] - - def test_validate_partial_tools_raises(self, mock_async_openai: MagicMock) -> None: - """Test that partial tool provision raises error.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [ - create_function_tool("get_weather"), - create_function_tool("search_database"), - ] - - with pytest.raises(ValueError) as exc_info: - provider._validate_function_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage] - - assert "search_database" in str(exc_info.value) - - -# endregion - -# region Tool Merging Tests - - -class TestToolMerging: - """Tests for tool merging.""" - - def test_merge_code_interpreter(self, mock_async_openai: MagicMock) -> None: - """Test merging code interpreter tool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool()] - - merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert len(merged) == 1 - assert merged[0] == {"type": "code_interpreter"} - - def test_merge_file_search(self, mock_async_openai: MagicMock) -> None: - """Test merging file search tool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_file_search_tool()] - - merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert len(merged) == 1 - assert merged[0] == {"type": "file_search"} - - def test_merge_with_user_tools(self, mock_async_openai: MagicMock) -> None: - """Test merging hosted and user tools.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool()] - - merged = provider._merge_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage] - - assert len(merged) == 2 - assert merged[0] == {"type": "code_interpreter"} - - def test_merge_multiple_hosted_tools(self, mock_async_openai: MagicMock) -> None: - """Test merging multiple hosted tools.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool(), create_file_search_tool()] - - merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert len(merged) == 2 - - def test_merge_single_user_tool(self, mock_async_openai: MagicMock) -> None: - """Test merging with single user tool (not list).""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools: list[Any] = [] - - merged = provider._merge_tools(assistant_tools, get_weather) # type: ignore[reportPrivateUsage] - - assert len(merged) == 1 - - -# endregion diff --git a/python/packages/openai/tests/openai/test_openai_assistants_client.py b/python/packages/openai/tests/openai/test_openai_assistants_client.py deleted file mode 100644 index ecb211001d..0000000000 --- a/python/packages/openai/tests/openai/test_openai_assistants_client.py +++ /dev/null @@ -1,1481 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import inspect -import json -import logging -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - ChatResponseUpdate, - Content, - Message, - SupportsChatGetResponse, - SupportsCodeInterpreterTool, - SupportsFileSearchTool, - SupportsImageGenerationTool, - SupportsMCPTool, - SupportsWebSearchTool, - tool, -) -from openai.types.beta.threads import ( - FileCitationAnnotation, - FilePathAnnotation, - MessageDeltaEvent, - Run, - TextDeltaBlock, -) -from openai.types.beta.threads import ( - Message as ThreadMessage, -) -from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation -from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation -from openai.types.beta.threads.runs import RunStep -from pydantic import Field - -from agent_framework_openai import OpenAIAssistantsClient - -pytestmark = pytest.mark.filterwarnings("ignore:OpenAIAssistantsClient is deprecated\\..*:DeprecationWarning") - - -def create_test_openai_assistants_client( - mock_async_openai: MagicMock, - model: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - thread_id: str | None = None, - should_delete_assistant: bool = False, -) -> OpenAIAssistantsClient: - """Helper function to create OpenAIAssistantsClient instances for testing.""" - client = OpenAIAssistantsClient( - model=model or "gpt-4", - assistant_id=assistant_id, - assistant_name=assistant_name, - thread_id=thread_id, - api_key="test-api-key", - org_id="test-org-id", - async_client=mock_async_openai, - ) - # Set the _should_delete_assistant flag directly if needed - if should_delete_assistant: - object.__setattr__(client, "_should_delete_assistant", True) - return client - - -async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, Content]: - """Create a vector store with sample documents for testing.""" - file = await client.client.files.create( - file=("todays_weather.txt", b"The weather today is sunny with a high of 25C."), purpose="user_data" - ) - vector_store = await client.client.vector_stores.create( - name="knowledge_base", - expires_after={"anchor": "last_active_at", "days": 1}, - ) - result = await client.client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id) - if result.last_error is not None: - raise Exception(f"Vector store file processing failed with status: {result.last_error.message}") - - return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) - - -async def delete_vector_store(client: OpenAIAssistantsClient, file_id: str, vector_store_id: str) -> None: - """Delete the vector store after tests.""" - - await client.client.vector_stores.delete(vector_store_id=vector_store_id) - await client.client.files.delete(file_id=file_id) - - -@pytest.fixture -def mock_async_openai() -> MagicMock: - """Mock AsyncOpenAI client.""" - mock_client = MagicMock() - - # Mock beta.assistants - mock_client.beta.assistants.create = AsyncMock(return_value=MagicMock(id="test-assistant-id")) - mock_client.beta.assistants.delete = AsyncMock() - - # Mock beta.threads - mock_client.beta.threads.create = AsyncMock(return_value=MagicMock(id="test-thread-id")) - mock_client.beta.threads.delete = AsyncMock() - - # Mock beta.threads.runs - mock_client.beta.threads.runs.create = AsyncMock(return_value=MagicMock(id="test-run-id")) - mock_client.beta.threads.runs.retrieve = AsyncMock() - mock_client.beta.threads.runs.submit_tool_outputs = AsyncMock() - mock_client.beta.threads.runs.cancel = AsyncMock() - - # Mock beta.threads.messages - mock_client.beta.threads.messages.create = AsyncMock() - mock_client.beta.threads.messages.list = AsyncMock(return_value=MagicMock(data=[])) - - return mock_client - - -def test_openai_assistants_client_is_deprecated(mock_async_openai: MagicMock) -> None: - with pytest.warns(DeprecationWarning, match="OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead."): - OpenAIAssistantsClient(model="gpt-4", api_key="test-api-key", async_client=mock_async_openai) - - -def test_openai_assistants_client_init_keeps_var_keyword() -> None: - signature = inspect.signature(OpenAIAssistantsClient.__init__) - - assert any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) - - -def test_openai_assistants_client_supports_code_interpreter_and_file_search() -> None: - assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool) - assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool) - assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool) - assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool) - assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool) - - -def test_init_with_client(mock_async_openai: MagicMock) -> None: - """Test OpenAIAssistantsClient initialization with existing client.""" - client = create_test_openai_assistants_client( - mock_async_openai, model="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id" - ) - - assert client.client is mock_async_openai - assert client.model == "gpt-4" - assert client.assistant_id == "existing-assistant-id" - assert client.thread_id == "test-thread-id" - assert not client._should_delete_assistant # type: ignore - assert isinstance(client, SupportsChatGetResponse) - - -def test_init_auto_create_client( - openai_unit_test_env: dict[str, str], - mock_async_openai: MagicMock, -) -> None: - """Test OpenAIAssistantsClient initialization with auto-created client.""" - client = OpenAIAssistantsClient( - model=openai_unit_test_env["OPENAI_MODEL"], - assistant_name="TestAssistant", - api_key=openai_unit_test_env["OPENAI_API_KEY"], - org_id=openai_unit_test_env["OPENAI_ORG_ID"], - async_client=mock_async_openai, - ) - - assert client.client is mock_async_openai - assert client.model == openai_unit_test_env["OPENAI_MODEL"] - assert client.assistant_id is None - assert client.assistant_name == "TestAssistant" - assert not client._should_delete_assistant # type: ignore - - -def test_init_validation_fail() -> None: - """Test OpenAIAssistantsClient initialization with validation failure.""" - with pytest.raises(ValueError): - # Force failure by providing invalid model ID type - OpenAIAssistantsClient(model=123, api_key="valid-key") # type: ignore - - -@pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) -def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None: - """Test OpenAIAssistantsClient initialization with missing model ID.""" - with pytest.raises(ValueError): - OpenAIAssistantsClient(api_key=openai_unit_test_env.get("OPENAI_API_KEY", "test-key")) - - -@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) -def test_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None: - """Test OpenAIAssistantsClient initialization with missing API key.""" - with pytest.raises(ValueError): - OpenAIAssistantsClient(model="gpt-4") - - -def test_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None: - """Test OpenAIAssistantsClient initialization with default headers.""" - default_headers = {"X-Unit-Test": "test-guid"} - - client = OpenAIAssistantsClient( - model="gpt-4", - api_key=openai_unit_test_env["OPENAI_API_KEY"], - default_headers=default_headers, - ) - - assert client.model == "gpt-4" - assert isinstance(client, SupportsChatGetResponse) - - # Assert that the default header we added is present in the client's default headers - for key, value in default_headers.items(): - assert key in client.client.default_headers - assert client.client.default_headers[key] == value - - -async def test_get_assistant_id_or_create_existing_assistant( - mock_async_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when assistant_id is already provided.""" - client = create_test_openai_assistants_client(mock_async_openai, assistant_id="existing-assistant-id") - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "existing-assistant-id" - assert not client._should_delete_assistant # type: ignore - mock_async_openai.beta.assistants.create.assert_not_called() - - -async def test_get_assistant_id_or_create_create_new( - mock_async_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when creating a new assistant.""" - client = create_test_openai_assistants_client(mock_async_openai, model="gpt-4", assistant_name="TestAssistant") - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "test-assistant-id" - assert client._should_delete_assistant # type: ignore - mock_async_openai.beta.assistants.create.assert_called_once() - - -async def test_aclose_should_not_delete( - mock_async_openai: MagicMock, -) -> None: - """Test close when assistant should not be deleted.""" - client = create_test_openai_assistants_client( - mock_async_openai, assistant_id="assistant-to-keep", should_delete_assistant=False - ) - - await client.close() # type: ignore - - # Verify assistant deletion was not called - mock_async_openai.beta.assistants.delete.assert_not_called() - assert not client._should_delete_assistant # type: ignore - - -async def test_aclose_should_delete(mock_async_openai: MagicMock) -> None: - """Test close method calls cleanup.""" - client = create_test_openai_assistants_client( - mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - await client.close() - - # Verify assistant deletion was called - mock_async_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - assert not client._should_delete_assistant # type: ignore - - -async def test_async_context_manager(mock_async_openai: MagicMock) -> None: - """Test async context manager functionality.""" - client = create_test_openai_assistants_client( - mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - # Test context manager - async with client: - pass # Just test that we can enter and exit - - # Verify cleanup was called on exit - mock_async_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - - -def test_serialize(openai_unit_test_env: dict[str, str]) -> None: - """Test serialization of OpenAIAssistantsClient.""" - default_headers = {"X-Unit-Test": "test-guid"} - - # Test basic initialization and to_dict - client = OpenAIAssistantsClient( - model="gpt-4", - assistant_id="test-assistant-id", - assistant_name="TestAssistant", - thread_id="test-thread-id", - api_key=openai_unit_test_env["OPENAI_API_KEY"], - org_id=openai_unit_test_env["OPENAI_ORG_ID"], - default_headers=default_headers, - ) - - dumped_settings = client.to_dict() - - assert dumped_settings["model"] == "gpt-4" - assert dumped_settings["assistant_id"] == "test-assistant-id" - assert dumped_settings["assistant_name"] == "TestAssistant" - assert dumped_settings["thread_id"] == "test-thread-id" - assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"] - - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - # Assert that the 'User-Agent' header is not present in the dumped_settings default headers - assert "User-Agent" not in dumped_settings["default_headers"] - - -async def test_get_active_thread_run_none_thread_id(mock_async_openai: MagicMock) -> None: - """Test _get_active_thread_run with None thread_id returns None.""" - client = create_test_openai_assistants_client(mock_async_openai) - - result = await client._get_active_thread_run(None) # type: ignore - - assert result is None - # Should not call the API when thread_id is None - mock_async_openai.beta.threads.runs.list.assert_not_called() - - -async def test_get_active_thread_run_with_active_run(mock_async_openai: MagicMock) -> None: - """Test _get_active_thread_run finds an active run.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock an active run (status not in completed states) - mock_run = MagicMock() - mock_run.status = "in_progress" # Active status - - # Mock the async iterator for runs.list - async def mock_runs_list(*args: Any, **kwargs: Any) -> Any: - yield mock_run - - mock_async_openai.beta.threads.runs.list.return_value.__aiter__ = mock_runs_list - - result = await client._get_active_thread_run("thread-123") # type: ignore - - assert result == mock_run - mock_async_openai.beta.threads.runs.list.assert_called_once_with(thread_id="thread-123", limit=1, order="desc") - - -async def test_prepare_thread_create_new(mock_async_openai: MagicMock) -> None: - """Test _prepare_thread creates new thread when thread_id is None.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock thread creation - mock_thread = MagicMock() - mock_thread.id = "new-thread-123" - mock_async_openai.beta.threads.create.return_value = mock_thread - - # Prepare run options with additional messages - run_options: dict[str, Any] = { - "additional_messages": [{"role": "user", "content": "Hello"}], - "tool_resources": {"code_interpreter": {}}, - "metadata": {"test": "true"}, - } - - result = await client._prepare_thread(None, None, run_options) # type: ignore - - assert result == "new-thread-123" - assert run_options["additional_messages"] == [] # Should be cleared - mock_async_openai.beta.threads.create.assert_called_once_with( - messages=[{"role": "user", "content": "Hello"}], - tool_resources={"code_interpreter": {}}, - metadata={"test": "true"}, - ) - - -async def test_prepare_thread_cancel_existing_run(mock_async_openai: MagicMock) -> None: - """Test _prepare_thread cancels existing run when provided.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock an existing thread run - mock_thread_run = MagicMock() - mock_thread_run.id = "run-456" - - run_options: dict[str, Any] = {"additional_messages": []} - - result = await client._prepare_thread("thread-123", mock_thread_run, run_options) # type: ignore - - assert result == "thread-123" - mock_async_openai.beta.threads.runs.cancel.assert_called_once_with(run_id="run-456", thread_id="thread-123") - - -async def test_prepare_thread_existing_no_run(mock_async_openai: MagicMock) -> None: - """Test _prepare_thread with existing thread_id but no active run.""" - client = create_test_openai_assistants_client(mock_async_openai) - - run_options: dict[str, list[dict[str, str]]] = {"additional_messages": []} - - result = await client._prepare_thread("thread-123", None, run_options) # type: ignore - - assert result == "thread-123" - # Should not call cancel since no thread_run provided - mock_async_openai.beta.threads.runs.cancel.assert_not_called() - - -async def test_process_stream_events_thread_run_created(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.run.created event.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock stream response for thread.run.created - mock_response = MagicMock() - mock_response.event = "thread.run.created" - mock_response.data = MagicMock() - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream that yields the response - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-123" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one ChatResponseUpdate for thread.run.created - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert update.contents == [] - assert update.raw_representation == mock_response.data - - -async def test_process_stream_events_message_delta_text(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.message.delta event containing text.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock TextDeltaBlock with proper spec - mock_delta_block = MagicMock(spec=TextDeltaBlock) - mock_delta_block.text = MagicMock() - mock_delta_block.text.value = "Hello from assistant" - - mock_delta = MagicMock() - mock_delta.role = "assistant" - mock_delta.content = [mock_delta_block] - - mock_message_delta = MagicMock(spec=MessageDeltaEvent) - mock_message_delta.delta = mock_delta - - mock_response = MagicMock() - mock_response.event = "thread.message.delta" - mock_response.data = mock_message_delta - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-456" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one text update - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert update.text == "Hello from assistant" - assert update.raw_representation == mock_message_delta - - -async def test_process_stream_events_message_delta_text_with_file_citation_annotations( - mock_async_openai: MagicMock, -) -> None: - """Test _process_stream_events maps file citation annotations from TextDeltaBlock.""" - client = create_test_openai_assistants_client(mock_async_openai) - - mock_annotation = FileCitationDeltaAnnotation( - index=0, - type="file_citation", - file_citation={"file_id": "file-abc123"}, - start_index=10, - end_index=24, - text="【4:0†source】", - ) - - mock_delta_block = MagicMock(spec=TextDeltaBlock) - mock_delta_block.text = MagicMock() - mock_delta_block.text.value = "Some text 【4:0†source】 more text" - mock_delta_block.text.annotations = [mock_annotation] - - mock_delta = MagicMock() - mock_delta.role = "assistant" - mock_delta.content = [mock_delta_block] - - mock_message_delta = MagicMock(spec=MessageDeltaEvent) - mock_message_delta.delta = mock_delta - - mock_response = MagicMock() - mock_response.event = "thread.message.delta" - mock_response.data = mock_message_delta - - async def async_iterator() -> Any: - yield mock_response - - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-789" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - assert len(updates) == 1 - update = updates[0] - assert update.text == "Some text 【4:0†source】 more text" - assert update.contents is not None - content = update.contents[0] - assert content.annotations is not None - assert len(content.annotations) == 1 - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-abc123" - assert ann["annotated_regions"] is not None - assert ann["annotated_regions"][0]["start_index"] == 10 - assert ann["annotated_regions"][0]["end_index"] == 24 - assert ann["additional_properties"]["text"] == "【4:0†source】" - - -async def test_process_stream_events_message_delta_text_with_file_path_annotations( - mock_async_openai: MagicMock, -) -> None: - """Test _process_stream_events maps file path annotations from TextDeltaBlock.""" - client = create_test_openai_assistants_client(mock_async_openai) - - mock_annotation = FilePathDeltaAnnotation( - index=0, - type="file_path", - file_path={"file_id": "file-xyz789"}, - start_index=5, - end_index=20, - text="sandbox:/path/to/file", - ) - - mock_delta_block = MagicMock(spec=TextDeltaBlock) - mock_delta_block.text = MagicMock() - mock_delta_block.text.value = "Here sandbox:/path/to/file is the file" - mock_delta_block.text.annotations = [mock_annotation] - - mock_delta = MagicMock() - mock_delta.role = "assistant" - mock_delta.content = [mock_delta_block] - - mock_message_delta = MagicMock(spec=MessageDeltaEvent) - mock_message_delta.delta = mock_delta - - mock_response = MagicMock() - mock_response.event = "thread.message.delta" - mock_response.data = mock_message_delta - - async def async_iterator() -> Any: - yield mock_response - - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-annotation" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.annotations is not None - assert len(content.annotations) == 1 - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-xyz789" - assert ann["annotated_regions"] is not None - assert ann["annotated_regions"][0]["start_index"] == 5 - assert ann["annotated_regions"][0]["end_index"] == 20 - - -async def test_process_stream_events_requires_action(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.run.requires_action event.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock the _parse_function_calls_from_assistants method to return test content - test_function_content = Content.from_function_call(call_id="call-123", name="test_func", arguments={"arg": "value"}) - client._parse_function_calls_from_assistants = MagicMock(return_value=[test_function_content]) # type: ignore - - # Create a mock Run object - mock_run = MagicMock(spec=Run) - - mock_response = MagicMock() - mock_response.event = "thread.run.requires_action" - mock_response.data = mock_run - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-789" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one function call update - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert len(update.contents) == 1 - assert update.contents[0] == test_function_content - assert update.raw_representation == mock_run - - # Verify _parse_function_calls_from_assistants was called correctly - client._parse_function_calls_from_assistants.assert_called_once_with(mock_run, None) # type: ignore - - -async def test_process_stream_events_run_step_created(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.run.step.created event.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock RunStep object - mock_run_step = MagicMock(spec=RunStep) - mock_run_step.run_id = "run-456" - - mock_response = MagicMock() - mock_response.event = "thread.run.step.created" - mock_response.data = mock_run_step - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-789" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # The run step creation itself doesn't yield an update, - # but it should set the response_id for subsequent events - assert len(updates) == 0 - - -async def test_process_stream_events_run_completed_with_usage( - mock_async_openai: MagicMock, -) -> None: - """Test _process_stream_events with thread.run.completed event containing usage.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock Run object with usage information - mock_usage = MagicMock() - mock_usage.prompt_tokens = 100 - mock_usage.completion_tokens = 50 - mock_usage.total_tokens = 150 - - mock_run = MagicMock(spec=Run) - mock_run.usage = mock_usage - - mock_response = MagicMock() - mock_response.event = "thread.run.completed" - mock_response.data = mock_run - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-999" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one usage update - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert len(update.contents) == 1 - - # Check the usage content - usage_content = update.contents[0] - assert usage_content.type == "usage" - assert usage_content.usage_details["input_token_count"] == 100 - assert usage_content.usage_details["output_token_count"] == 50 - assert usage_content.usage_details["total_token_count"] == 150 - assert update.raw_representation == mock_run - - -def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock) -> None: - """Test _parse_function_calls_from_assistants with a simple function call.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock Run event that requires action - mock_run = MagicMock() - mock_run.required_action = MagicMock() - mock_run.required_action.submit_tool_outputs = MagicMock() - - # Create a mock tool call - mock_tool_call = MagicMock() - mock_tool_call.id = "call_abc123" - mock_tool_call.function.name = "get_weather" - mock_tool_call.function.arguments = '{"location": "Seattle"}' - - mock_run.required_action.submit_tool_outputs.tool_calls = [mock_tool_call] - - # Call the method - response_id = "response_456" - contents = client._parse_function_calls_from_assistants(mock_run, response_id) # type: ignore - - # Test that one function call content was created - assert len(contents) == 1 - assert contents[0].type == "function_call" - assert contents[0].name == "get_weather" - assert contents[0].arguments == {"location": "Seattle"} - - -def test_parse_run_step_with_code_interpreter_tool_call(mock_async_openai: MagicMock) -> None: - """Test _parse_run_step_tool_call with code_interpreter type creates CodeInterpreterToolCallContent.""" - client = create_test_openai_assistants_client( - mock_async_openai, - model="test-model", - assistant_id="test-assistant", - ) - - # Mock a run with required_action containing code_interpreter tool call - mock_run = MagicMock() - mock_run.id = "run_123" - mock_run.status = "requires_action" - - mock_tool_call = MagicMock() - mock_tool_call.id = "call_code_123" - mock_tool_call.type = "code_interpreter" - mock_code_interpreter = MagicMock() - mock_code_interpreter.input = "print('Hello, World!')" - mock_tool_call.code_interpreter = mock_code_interpreter - - mock_required_action = MagicMock() - mock_required_action.submit_tool_outputs = MagicMock() - mock_required_action.submit_tool_outputs.tool_calls = [mock_tool_call] - mock_run.required_action = mock_required_action - - # Parse the run step - contents = client._parse_function_calls_from_assistants(mock_run, "response_123") - - # Should have CodeInterpreterToolCallContent - assert len(contents) == 1 - assert contents[0].type == "code_interpreter_tool_call" - assert contents[0].call_id == '["response_123", "call_code_123"]' - assert contents[0].inputs is not None - assert len(contents[0].inputs) == 1 - assert contents[0].inputs[0].type == "text" - assert contents[0].inputs[0].text == "print('Hello, World!')" - - -def test_parse_run_step_with_mcp_tool_call(mock_async_openai: MagicMock) -> None: - """Test _parse_run_step_tool_call with mcp type creates MCPServerToolCallContent.""" - client = create_test_openai_assistants_client( - mock_async_openai, - model="test-model", - assistant_id="test-assistant", - ) - - # Mock a run with required_action containing mcp tool call - mock_run = MagicMock() - mock_run.id = "run_456" - mock_run.status = "requires_action" - - mock_tool_call = MagicMock() - mock_tool_call.id = "call_mcp_456" - mock_tool_call.type = "mcp" - mock_tool_call.name = "fetch_data" - mock_tool_call.server_label = "DataServer" - mock_tool_call.args = {"key": "value"} - - mock_required_action = MagicMock() - mock_required_action.submit_tool_outputs = MagicMock() - mock_required_action.submit_tool_outputs.tool_calls = [mock_tool_call] - mock_run.required_action = mock_required_action - - # Parse the run step - contents = client._parse_function_calls_from_assistants(mock_run, "response_456") - - # Should have MCPServerToolCallContent - assert len(contents) == 1 - assert contents[0].type == "mcp_server_tool_call" - assert contents[0].call_id == '["response_456", "call_mcp_456"]' - assert contents[0].tool_name == "fetch_data" - assert contents[0].server_name == "DataServer" - assert contents[0].arguments == {"key": "value"} - - -def test_prepare_options_basic(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with basic chat options.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create basic chat options as a dict - options = { - "max_tokens": 100, - "model": "gpt-4", - "temperature": 0.7, - "top_p": 0.9, - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check basic options were set - assert run_options["max_completion_tokens"] == 100 - assert run_options["model"] == "gpt-4" - assert run_options["temperature"] == 0.7 - assert run_options["top_p"] == 0.9 - assert "tool_choice" not in run_options - assert tool_results is None - - -def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with a FunctionTool.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a simple function for testing and decorate it - @tool(approval_mode="never_require") - def test_function(query: str) -> str: - """A test function.""" - return f"Result for {query}" - - options = { - "tools": [test_function], - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check tools were set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0]["type"] == "function" - assert "function" in run_options["tools"][0] - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_with_tools_without_tool_choice(mock_async_openai: MagicMock) -> None: - """Test _prepare_options keeps tool_choice unset when not provided.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - @tool(approval_mode="never_require") - def test_function(query: str) -> str: - """A test function.""" - return f"Result for {query}" - - options = { - "tools": [test_function], - } - - messages = [Message(role="user", text="Hello")] - run_options, _ = client._prepare_options(messages, options) # type: ignore - - assert "tools" in run_options - assert "tool_choice" not in run_options - - -def test_prepare_options_with_single_tool_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with a single FunctionTool (non-sequence).""" - client = create_test_openai_assistants_client(mock_async_openai) - - @tool(approval_mode="never_require") - def test_function(query: str) -> str: - """A test function.""" - return f"Result for {query}" - - options = { - "tools": test_function, - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Hello")] - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0]["type"] == "function" - assert "function" in run_options["tools"][0] - assert run_options["tool_choice"] == "auto" - assert tool_results is None - - -def test_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with code interpreter tool.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a code interpreter tool dict - code_tool = OpenAIAssistantsClient.get_code_interpreter_tool() - - options = { - "tools": [code_tool], - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Calculate something")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check code interpreter tool was set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0] == {"type": "code_interpreter"} - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_tool_choice_none(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with tool_choice set to 'none' and no tools.""" - client = create_test_openai_assistants_client(mock_async_openai) - - options = { - "tool_choice": "none", - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Should set tool_choice to none - no tools because none were provided - assert run_options["tool_choice"] == "none" - assert "tools" not in run_options - - -def test_prepare_options_tool_choice_none_with_tools(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with tool_choice='none' but tools provided. - - When tool_choice='none', the model won't call tools, but tools should still - be sent to the API so they're available for future turns in the conversation. - """ - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a function tool - @tool(approval_mode="never_require") - def test_func(arg: str) -> str: - return arg - - options = { - "tool_choice": "none", - "tools": [test_func], - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Should set tool_choice to none BUT still include tools - assert run_options["tool_choice"] == "none" - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - - -def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with required function tool choice.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a required function tool choice as dict - tool_choice = {"mode": "required", "required_function_name": "specific_function"} - - options = { - "tool_choice": tool_choice, - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check required function tool choice was set correctly - expected_tool_choice = { - "type": "function", - "function": {"name": "specific_function"}, - } - assert run_options["tool_choice"] == expected_tool_choice - - -def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with file_search tool.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a file_search tool with max_results - file_search_tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10) - - options = { - "tools": [file_search_tool], - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Search for information")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check file search tool was set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - expected_tool = {"type": "file_search", "file_search": {"max_num_results": 10}} - assert run_options["tools"][0] == expected_tool - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_with_mapping_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with MutableMapping tool.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a tool as a MutableMapping (dict) - mapping_tool = {"type": "custom_tool", "parameters": {"setting": "value"}} - - options = { - "tools": [mapping_tool], # type: ignore - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Use custom tool")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check mapping tool was set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0] == mapping_tool - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_with_pydantic_response_format(mock_async_openai: MagicMock) -> None: - """Test _prepare_options sets strict=True for Pydantic response_format.""" - from pydantic import BaseModel, ConfigDict - - class TestResponse(BaseModel): - name: str - value: int - model_config = ConfigDict(extra="forbid") - - client = create_test_openai_assistants_client(mock_async_openai) - messages = [Message(role="user", text="Test")] - options = {"response_format": TestResponse} - - run_options, _ = client._prepare_options(messages, options) # type: ignore - - assert "response_format" in run_options - assert run_options["response_format"]["type"] == "json_schema" - assert run_options["response_format"]["json_schema"]["name"] == "TestResponse" - assert run_options["response_format"]["json_schema"]["strict"] is True - - -def test_prepare_options_with_system_message(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with system message converted to instructions.""" - client = create_test_openai_assistants_client(mock_async_openai) - - messages = [ - Message(role="system", text="You are a helpful assistant."), - Message(role="user", text="Hello"), - ] - - # Call the method - run_options, tool_results = client._prepare_options(messages, {}) # type: ignore - - # Check that additional_messages only contains the user message - # System message should be converted to instructions (though this is handled internally) - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 - assert run_options["additional_messages"][0]["role"] == "user" - - -def test_prepare_options_with_image_content(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with image content.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create message with image content - image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg") - messages = [Message(role="user", contents=[image_content])] - - # Call the method - run_options, tool_results = client._prepare_options(messages, {}) # type: ignore - - # Check that image content was processed - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 - message = run_options["additional_messages"][0] - assert message["role"] == "user" - assert len(message["content"]) == 1 - assert message["content"][0]["type"] == "image_url" - assert message["content"][0]["image_url"]["url"] == "https://example.com/image.jpg" - - -def test_prepare_tool_outputs_for_assistants_empty(mock_async_openai: MagicMock) -> None: - """Test _prepare_tool_outputs_for_assistants with empty list.""" - client = create_test_openai_assistants_client(mock_async_openai) - - run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([]) # type: ignore - - assert run_id is None - assert tool_outputs is None - - -def test_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock) -> None: - """Test _prepare_tool_outputs_for_assistants with valid function results.""" - client = create_test_openai_assistants_client(mock_async_openai) - - call_id = json.dumps(["run-123", "call-456"]) - function_result = Content.from_function_result(call_id=call_id, result="Function executed successfully") - - run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([function_result]) # type: ignore - - assert run_id == "run-123" - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].get("tool_call_id") == "call-456" - assert tool_outputs[0].get("output") == "Function executed successfully" - - -def test_prepare_tool_outputs_for_assistants_mismatched_run_ids( - mock_async_openai: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_assistants with mismatched run IDs.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create function results with different run IDs - call_id1 = json.dumps(["run-123", "call-456"]) - call_id2 = json.dumps(["run-789", "call-xyz"]) # Different run ID - function_result1 = Content.from_function_result(call_id=call_id1, result="Result 1") - function_result2 = Content.from_function_result(call_id=call_id2, result="Result 2") - - run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([function_result1, function_result2]) # type: ignore - - # Should only process the first one since run IDs don't match - assert run_id == "run-123" - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].get("tool_call_id") == "call-456" - - -def test_update_agent_name_and_description(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name_and_description method updates assistant_name when not already set.""" - # Test updating agent name when assistant_name is None - client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) - - # Call the private method to update agent name - client._update_agent_name_and_description("New Assistant Name") # type: ignore - - assert client.assistant_name == "New Assistant Name" - - -def test_update_agent_name_and_description_existing(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name_and_description method doesn't override existing assistant_name.""" - # Test that existing assistant_name is not overridden - client = create_test_openai_assistants_client(mock_async_openai, assistant_name="Existing Assistant") - - # Call the private method to update agent name - client._update_agent_name_and_description("New Assistant Name") # type: ignore - - # Should keep the existing name - assert client.assistant_name == "Existing Assistant" - - -def test_update_agent_name_and_description_none(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name_and_description method with None agent_name parameter.""" - # Test that None agent_name doesn't change anything - client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) - - # Call the private method with None - client._update_agent_name_and_description(None) # type: ignore - - # Should remain None - assert client.assistant_name is None - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -# Callable API Key Tests -def test_with_callable_api_key() -> None: - """Test OpenAIAssistantsClient initialization with callable API key.""" - - async def get_api_key() -> str: - return "test-api-key-123" - - client = OpenAIAssistantsClient(model="gpt-4o", api_key=get_api_key) - - # Verify client was created successfully - assert client.model == "gpt-4o" - # OpenAI SDK now manages callable API keys internally - assert client.client is not None - - -# region thread.message.completed helpers - - -def _make_stream_event(event: str, data: Any) -> MagicMock: - """Create a mock stream event.""" - mock = MagicMock() - mock.event = event - mock.data = data - return mock - - -def _make_text_block(text_value: str, annotations: list | None = None) -> MagicMock: - """Create a mock TextContentBlock with optional annotations.""" - block = MagicMock() - block.type = "text" - block.text = MagicMock() - block.text.value = text_value - block.text.annotations = annotations or [] - return block - - -def _make_image_block() -> MagicMock: - """Create a mock ImageContentBlock (non-text block).""" - block = MagicMock() - block.type = "image_file" - return block - - -def _make_file_citation_annotation( - text: str = "【4:0†source】", - file_id: str = "file-abc123", - start_index: int = 10, - end_index: int = 24, -) -> MagicMock: - """Create a mock FileCitationAnnotation.""" - annotation = MagicMock(spec=FileCitationAnnotation) - annotation.text = text - annotation.start_index = start_index - annotation.end_index = end_index - annotation.file_citation = MagicMock() - annotation.file_citation.file_id = file_id - return annotation - - -def _make_file_path_annotation( - text: str = "sandbox:/file.csv", - file_id: str = "file-xyz789", - start_index: int = 5, - end_index: int = 22, -) -> MagicMock: - """Create a mock FilePathAnnotation.""" - annotation = MagicMock(spec=FilePathAnnotation) - annotation.text = text - annotation.start_index = start_index - annotation.end_index = end_index - annotation.file_path = MagicMock() - annotation.file_path.file_id = file_id - return annotation - - -def _make_unknown_annotation() -> MagicMock: - """Create a mock annotation of an unrecognized type.""" - annotation = MagicMock() - annotation.__class__.__name__ = "FutureAnnotationType" - return annotation - - -def _make_thread_message(content_blocks: list) -> MagicMock: - """Create a mock ThreadMessage.""" - msg = MagicMock(spec=ThreadMessage) - msg.content = content_blocks - return msg - - -async def _collect_updates(client, stream_events, thread_id="thread_123"): - """Helper to collect ChatResponseUpdate objects from _process_stream_events.""" - - class MockAsyncStream: - def __init__(self, events): - self._events = events - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - def __aiter__(self): - return self - - async def __anext__(self): - if not self._events: - raise StopAsyncIteration - return self._events.pop(0) - - mock_stream = MockAsyncStream(list(stream_events)) - results = [] - async for update in client._process_stream_events(mock_stream, thread_id): - results.append(update) - return results - - -# endregion - - -class TestMessageCompletedAnnotations: - """Tests for thread.message.completed event handling.""" - - @pytest.fixture - def client(self): - """Create a client instance for testing.""" - with patch.object(OpenAIAssistantsClient, "__init__", lambda self, **kw: None): - return object.__new__(OpenAIAssistantsClient) - - @pytest.mark.asyncio - async def test_message_completed_with_file_citation(self, client): - """Verify file citation annotations are extracted from completed messages.""" - citation = _make_file_citation_annotation( - text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24 - ) - text_block = _make_text_block("Some text with a citation【4:0†source】", [citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - # Should yield exactly one update for the completed message - assert len(updates) == 1 - update = updates[0] - assert update.role == "assistant" - assert len(update.contents) == 1 - - content = update.contents[0] - assert content.text == "Some text with a citation【4:0†source】" - assert content.annotations is not None - assert len(content.annotations) == 1 - - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-abc123" - assert ann["annotated_regions"][0]["start_index"] == 10 - assert ann["annotated_regions"][0]["end_index"] == 24 - - @pytest.mark.asyncio - async def test_message_completed_with_file_path(self, client): - """Verify file path annotations are extracted from completed messages.""" - file_path = _make_file_path_annotation( - text="sandbox:/output.csv", file_id="file-xyz789", start_index=0, end_index=19 - ) - text_block = _make_text_block("sandbox:/output.csv", [file_path]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.annotations is not None - assert len(content.annotations) == 1 - - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-xyz789" - assert ann["annotated_regions"][0]["start_index"] == 0 - assert ann["annotated_regions"][0]["end_index"] == 19 - - @pytest.mark.asyncio - async def test_message_completed_multiple_annotations(self, client): - """Verify multiple annotations on a single text block are all captured.""" - cit1 = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=5, end_index=12) - cit2 = _make_file_citation_annotation(text="【2†src】", file_id="file-b", start_index=20, end_index=27) - text_block = _make_text_block("Hello【1†src】world【2†src】", [cit1, cit2]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - assert len(updates[0].contents[0].annotations) == 2 - assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" - assert updates[0].contents[0].annotations[1]["file_id"] == "file-b" - - @pytest.mark.asyncio - async def test_message_completed_no_annotations(self, client): - """Verify text-only completed messages produce content without annotations.""" - text_block = _make_text_block("Plain text response") - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.text == "Plain text response" - assert content.annotations is None or len(content.annotations) == 0 - - @pytest.mark.asyncio - async def test_message_completed_skips_non_text_blocks(self, client): - """Verify non-text content blocks (e.g., image_file) are skipped.""" - image_block = _make_image_block() - msg = _make_thread_message([image_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - # No text blocks → no update yielded - assert len(updates) == 0 - - @pytest.mark.asyncio - async def test_message_completed_mixed_blocks(self, client): - """Verify only text blocks are processed in mixed-content messages.""" - text_block = _make_text_block("Text content here") - image_block = _make_image_block() - msg = _make_thread_message([image_block, text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - assert len(updates[0].contents) == 1 - assert updates[0].contents[0].text == "Text content here" - - @pytest.mark.asyncio - async def test_message_completed_conversation_id_preserved(self, client): - """Verify the thread_id is correctly propagated as conversation_id.""" - text_block = _make_text_block("Response text") - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events, thread_id="thread_custom_456") - - assert len(updates) == 1 - assert updates[0].conversation_id == "thread_custom_456" - - @pytest.mark.asyncio - async def test_message_completed_unrecognized_annotation_logged(self, client, caplog): - """Verify unrecognized annotation types are logged at debug level and skipped.""" - unknown_ann = _make_unknown_annotation() - citation = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=0, end_index=7) - text_block = _make_text_block("Text【1†src】", [unknown_ann, citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - with caplog.at_level(logging.DEBUG, logger="agent_framework.openai"): - updates = await _collect_updates(client, events) - - # The known citation should still be processed - assert len(updates) == 1 - assert len(updates[0].contents[0].annotations) == 1 - assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" - - # The unrecognized annotation should have been logged - assert any("Unparsed annotation type" in record.message for record in caplog.records) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 87025cffe4..8c91048284 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -54,7 +54,7 @@ from openai.types.responses.response_text_delta_event import ResponseTextDeltaEv from pydantic import BaseModel from pytest import param -from agent_framework_openai import OpenAIChatClient, OpenAIResponsesClient +from agent_framework_openai import OpenAIChatClient from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY from agent_framework_openai._exceptions import OpenAIContentFilterException @@ -125,37 +125,37 @@ def test_init_uses_explicit_parameters() -> None: assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) -def test_deprecated_responses_client_supports_all_tool_protocols() -> None: - assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool) - assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool) - assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool) - assert isinstance(OpenAIResponsesClient, SupportsMCPTool) - assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) +def test_openai_chat_client_supports_all_tool_protocols() -> None: + assert isinstance(OpenAIChatClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIChatClient, SupportsWebSearchTool) + assert isinstance(OpenAIChatClient, SupportsImageGenerationTool) + assert isinstance(OpenAIChatClient, SupportsMCPTool) + assert isinstance(OpenAIChatClient, SupportsFileSearchTool) -def test_protocol_isinstance_with_responses_client_instance() -> None: - client = object.__new__(OpenAIResponsesClient) +def test_protocol_isinstance_with_openai_chat_client_instance() -> None: + client = object.__new__(OpenAIChatClient) assert isinstance(client, SupportsCodeInterpreterTool) assert isinstance(client, SupportsWebSearchTool) -def test_deprecated_responses_client_tool_methods_return_dict() -> None: - code_tool = OpenAIResponsesClient.get_code_interpreter_tool() +def test_openai_chat_client_tool_methods_return_dict() -> None: + code_tool = OpenAIChatClient.get_code_interpreter_tool() assert isinstance(code_tool, dict) assert code_tool.get("type") == "code_interpreter" - web_tool = OpenAIResponsesClient.get_web_search_tool() + web_tool = OpenAIChatClient.get_web_search_tool() assert isinstance(web_tool, dict) assert web_tool.get("type") == "web_search" -def test_init_prefers_openai_responses_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: - monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model_id") +def test_init_prefers_openai_chat_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model") openai_responses_client = OpenAIChatClient() - assert openai_responses_client.model == "test_responses_model_id" + assert openai_responses_client.model == "test_chat_model" def test_init_validation_fail() -> None: @@ -164,12 +164,12 @@ def test_init_validation_fail() -> None: OpenAIChatClient(api_key="34523", model={"test": "dict"}) # type: ignore -def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None: +def test_init_model_constructor(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization - model_id = "test_model_id" - openai_responses_client = OpenAIChatClient(model=model_id) + model = "test_model" + openai_responses_client = OpenAIChatClient(model=model) - assert openai_responses_client.model == model_id + assert openai_responses_client.model == model assert isinstance(openai_responses_client, SupportsChatGetResponse) @@ -191,18 +191,18 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) -def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: +def test_init_with_empty_model(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(SettingNotFoundError): OpenAIChatClient() @pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: - model_id = "test_model_id" + model = "test_model" with pytest.raises(SettingNotFoundError): OpenAIChatClient( - model=model_id, + model=model, ) @@ -255,7 +255,7 @@ async def test_get_response_with_all_parameters() -> None: """Test request preparation with a comprehensive parameter set.""" client = OpenAIChatClient(model="test-model", api_key="test-key") _, run_options, _ = await client._prepare_request( - messages=[Message(role="user", text="Test message")], + messages=[Message(role="user", contents=["Test message"])], options={ "include": ["message.output_text.logprobs"], "instructions": "You are a helpful assistant", @@ -320,7 +320,7 @@ async def test_web_search_tool_with_location() -> None: ) _, run_options, _ = await client._prepare_request( - messages=[Message(role="user", text="What's the weather?")], + messages=[Message(role="user", contents=["What's the weather?"])], options={"tools": [web_search_tool], "tool_choice": "auto"}, ) @@ -346,7 +346,7 @@ async def test_code_interpreter_tool_variations() -> None: code_tool_with_files = OpenAIChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) _, run_options, _ = await client._prepare_request( - messages=[Message(role="user", text="Process these files")], + messages=[Message(role="user", contents=["Process these files"])], options={"tools": [code_tool_with_files]}, ) @@ -367,7 +367,7 @@ async def test_content_filter_exception() -> None: with patch.object(client.client.responses, "create", side_effect=mock_error): with pytest.raises(OpenAIContentFilterException) as exc_info: - await client.get_response(messages=[Message(role="user", text="Test message")]) + await client.get_response(messages=[Message(role="user", contents=["Test message"])]) assert "content error" in str(exc_info.value) @@ -404,7 +404,7 @@ async def test_chat_message_parsing_with_function_calls() -> None: function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully") messages = [ - Message(role="user", text="Call a function"), + Message(role="user", contents=["Call a function"]), Message(role="assistant", contents=[function_call]), Message(role="tool", contents=[function_result]), ] @@ -450,7 +450,7 @@ async def test_response_format_parse_path() -> None: with patch.object(client.client.responses, "parse", return_value=mock_parsed_response): response = await client.get_response( - messages=[Message(role="user", text="Test message")], + messages=[Message(role="user", contents=["Test message"])], options={"response_format": OutputStruct, "store": True}, ) assert response.response_id == "parsed_response_123" @@ -477,7 +477,7 @@ async def test_response_format_parse_path_with_conversation_id() -> None: with patch.object(client.client.responses, "parse", return_value=mock_parsed_response): response = await client.get_response( - messages=[Message(role="user", text="Test message")], + messages=[Message(role="user", contents=["Test message"])], options={"response_format": OutputStruct, "store": True}, ) assert response.response_id == "parsed_response_123" @@ -485,6 +485,46 @@ async def test_response_format_parse_path_with_conversation_id() -> None: assert response.model == "test-model" +async def test_response_format_dict_parse_path() -> None: + """Test get_response response_format parsing path for runtime JSON schema mappings.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + response_format = {"type": "object", "properties": {"answer": {"type": "string"}}} + + mock_response = MagicMock() + mock_response.id = "response_123" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.metadata = {} + mock_response.output_parsed = None + mock_response.output = [] + mock_response.usage = None + mock_response.finish_reason = None + mock_response.conversation = None + mock_response.status = "completed" + + mock_message_content = MagicMock() + mock_message_content.type = "output_text" + mock_message_content.text = '{"answer": "Parsed"}' + mock_message_content.annotations = [] + mock_message_content.logprobs = None + + mock_message_item = MagicMock() + mock_message_item.type = "message" + mock_message_item.content = [mock_message_content] + mock_response.output = [mock_message_item] + + with patch.object(client.client.responses, "create", return_value=mock_response): + response = await client.get_response( + messages=[Message(role="user", contents=["Test message"])], + options={"response_format": response_format}, + ) + + assert response.response_id == "response_123" + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["answer"] == "Parsed" + + async def test_bad_request_error_non_content_filter() -> None: """Test get_response BadRequestError without content_filter.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -500,7 +540,7 @@ async def test_bad_request_error_non_content_filter() -> None: with patch.object(client.client.responses, "parse", side_effect=mock_error): with pytest.raises(ChatClientException) as exc_info: await client.get_response( - messages=[Message(role="user", text="Test message")], + messages=[Message(role="user", contents=["Test message"])], options={"response_format": OutputStruct}, ) @@ -521,7 +561,7 @@ async def test_streaming_content_filter_exception_handling() -> None: mock_create.side_effect.code = "content_filter" with pytest.raises(OpenAIContentFilterException, match="service encountered a content error"): - response_stream = client.get_response(stream=True, messages=[Message(role="user", text="Test")]) + response_stream = client.get_response(stream=True, messages=[Message(role="user", contents=["Test"])]) async for _ in response_stream: break @@ -886,7 +926,7 @@ async def test_local_shell_tool_is_invoked_in_function_loop() -> None: with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create: await client.get_response( - messages=[Message(role="user", text="What Python version is available?")], + messages=[Message(role="user", contents=["What Python version is available?"])], options={"tools": [local_shell_tool]}, ) @@ -959,7 +999,7 @@ async def test_shell_call_is_invoked_as_local_shell_function_loop() -> None: with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create: await client.get_response( - messages=[Message(role="user", text="What Python version is available?")], + messages=[Message(role="user", contents=["What Python version is available?"])], options={"tools": [local_shell_tool]}, ) @@ -1224,8 +1264,8 @@ def test_prepare_messages_for_openai_assistant_history_uses_output_text_with_ann client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [ - Message(role="user", text="What is async/await?"), - Message(role="assistant", text="Async/await enables non-blocking concurrency."), + Message(role="user", contents=["What is async/await?"]), + Message(role="assistant", contents=["Async/await enables non-blocking concurrency."]), ] prepared = client._prepare_messages_for_openai(messages) @@ -2223,7 +2263,7 @@ async def test_end_to_end_mcp_approval_flow(span_exporter) -> None: # Patch the create call to return the two mocked responses in sequence with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create: # First call: get the approval request - response = await client.get_response(messages=[Message(role="user", text="Trigger approval")]) + response = await client.get_response(messages=[Message(role="user", contents=["Trigger approval"])]) assert response.messages[0].contents[0].type == "function_approval_request" req = response.messages[0].contents[0] assert req.id == "approval-1" @@ -2475,7 +2515,7 @@ def test_streaming_annotation_added_with_unknown_type() -> None: async def test_service_response_exception_includes_original_error_details() -> None: """Test that ChatClientException messages include original error details in the new format.""" client = OpenAIChatClient(model="test-model", api_key="test-key") - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] mock_response = MagicMock() original_error_message = "Request rate limit exceeded" @@ -2500,7 +2540,7 @@ async def test_service_response_exception_includes_original_error_details() -> N async def test_get_response_streaming_with_response_format() -> None: """Test get_response streaming with response_format.""" client = OpenAIChatClient(model="test-model", api_key="test-key") - messages = [Message(role="user", text="Test streaming with format")] + messages = [Message(role="user", contents=["Test streaming with format"])] # It will fail due to invalid API key, but exercises the code path with pytest.raises(ChatClientException): @@ -3050,7 +3090,7 @@ def test_parse_response_from_openai_image_generation_fallback(): async def test_prepare_options_store_parameter_handling() -> None: client = OpenAIChatClient(model="test-model", api_key="test-key") - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] test_conversation_id = "test-conversation-123" chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) @@ -3102,7 +3142,7 @@ async def test_instructions_sent_first_turn_then_skipped_for_continuation() -> N with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create: await client.get_response( - messages=[Message(role="user", text="Hello")], + messages=[Message(role="user", contents=["Hello"])], options={"instructions": "Reply in uppercase."}, ) @@ -3113,7 +3153,7 @@ async def test_instructions_sent_first_turn_then_skipped_for_continuation() -> N assert first_input_messages[1]["role"] == "user" await client.get_response( - messages=[Message(role="user", text="Tell me a joke")], + messages=[Message(role="user", contents=["Tell me a joke"])], options={ "instructions": "Reply in uppercase.", "conversation_id": "resp_123", @@ -3135,7 +3175,7 @@ async def test_instructions_not_repeated_for_continuation_ids( with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create: await client.get_response( - messages=[Message(role="user", text="Continue conversation")], + messages=[Message(role="user", contents=["Continue conversation"])], options={"instructions": "Be helpful.", "conversation_id": conversation_id}, ) @@ -3151,7 +3191,7 @@ async def test_instructions_included_without_conversation_id() -> None: with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create: await client.get_response( - messages=[Message(role="user", text="Hello")], + messages=[Message(role="user", contents=["Hello"])], options={"instructions": "You are a helpful assistant."}, ) @@ -3260,14 +3300,14 @@ async def test_integration_options( # Prepare test message if option_name.startswith("tools") or option_name.startswith("tool_choice"): # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", contents=["What is the weather in Seattle?"])] elif option_name.startswith("response_format"): # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) + messages = [Message(role="user", contents=["The weather in Seattle is sunny"])] + messages.append(Message(role="user", contents=["What is the weather in Seattle?"])) else: # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", contents=["Say 'Hello World' briefly."])] # Build options dict options: dict[str, Any] = {option_name: option_value} @@ -3297,12 +3337,10 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value + assert "seattle" in response.value["location"].lower() @pytest.mark.timeout(300) @@ -3320,7 +3358,7 @@ async def test_integration_web_search() -> None: "messages": [ Message( role="user", - text="What is the current weather? Do not ask for my current location.", + contents=["What is the current weather? Do not ask for my current location."], ) ], "options": { @@ -3352,7 +3390,7 @@ async def test_integration_file_search() -> None: messages=[ Message( role="user", - text="What is the weather today? Do a file search to find the answer.", + contents=["What is the weather today? Do a file search to find the answer."], ) ], options={ @@ -3386,7 +3424,7 @@ async def test_integration_streaming_file_search() -> None: messages=[ Message( role="user", - text="What is the weather today? Do a file search to find the answer.", + contents=["What is the weather today? Do a file search to find the answer."], ) ], options={ @@ -3430,7 +3468,7 @@ async def test_integration_tool_rich_content_image() -> None: messages = [ Message( role="user", - text="Call the get_test_image tool and describe what you see.", + contents=["Call the get_test_image tool and describe what you see."], ) ] options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index bda022e94a..4bec80f6b7 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import os from functools import wraps from pathlib import Path @@ -24,10 +23,7 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or ( - os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "") == "" - and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), + or (os.getenv("AZURE_OPENAI_CHAT_MODEL", "") == "" and os.getenv("AZURE_OPENAI_MODEL", "") == ""), reason="No real Azure OpenAI endpoint or responses deployment provided; skipping integration tests.", ) @@ -39,9 +35,7 @@ def _with_azure_openai_debug() -> Any: try: return await func(*args, **kwargs) except Exception as exc: - model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) + model = os.getenv("AZURE_OPENAI_CHAT_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "") api_version = os.getenv("AZURE_OPENAI_API_VERSION") or "preview" endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" @@ -102,7 +96,7 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" @@ -112,7 +106,7 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -147,7 +141,7 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ client = OpenAIChatClient(credential=lambda: "token") - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -155,34 +149,34 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ def test_init_falls_back_to_generic_azure_deployment_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False) client = OpenAIChatClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) def test_init_does_not_fall_back_to_openai_responses_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) - monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model") + monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) + monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_responses_model") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatClient() def test_init_does_not_fall_back_to_openai_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("OPENAI_RESPONSES_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) + monkeypatch.delenv("OPENAI_CHAT_MODEL", raising=False) monkeypatch.setenv("OPENAI_MODEL", "gpt-5") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatClient() @@ -209,7 +203,7 @@ def test_init_with_credential_wraps_async_token_credential( def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert client.api_version is not None @@ -291,14 +285,14 @@ async def test_integration_options( for streaming in [False, True]: if option_name in {"tools", "tool_choice"}: - messages = [Message(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", contents=["What is the weather in Seattle?"])] elif option_name == "response_format": messages = [ - Message(role="user", text="The weather in Seattle is sunny"), - Message(role="user", text="What is the weather in Seattle?"), + Message(role="user", contents=["The weather in Seattle is sunny"]), + Message(role="user", contents=["What is the weather in Seattle?"]), ] else: - messages = [Message(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", contents=["Say 'Hello World' briefly."])] options: dict[str, Any] = {option_name: option_value} if option_name == "tool_choice": @@ -327,11 +321,10 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - assert response.value is None - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value + assert "seattle" in response.value["location"].lower() @pytest.mark.flaky @@ -346,7 +339,7 @@ async def test_integration_web_search() -> None: messages=[ Message( role="user", - text="What is the current weather? Do not ask for my current location.", + contents=["What is the current weather? Do not ask for my current location."], ) ], options={ @@ -368,7 +361,9 @@ async def test_integration_client_file_search() -> None: file_id, vector_store = await create_vector_store(client) try: response = await client.get_response( - messages=[Message(role="user", text="What is the weather today? Do a file search to find the answer.")], + messages=[ + Message(role="user", contents=["What is the weather today? Do a file search to find the answer."]) + ], options={ "tools": [OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])], "tool_choice": "auto", @@ -391,7 +386,9 @@ async def test_integration_client_file_search_streaming() -> None: file_id, vector_store = await create_vector_store(client) try: response_stream = client.get_response( - messages=[Message(role="user", text="What is the weather today? Do a file search to find the answer.")], + messages=[ + Message(role="user", contents=["What is the weather today? Do a file search to find the answer."]) + ], stream=True, options={ "tools": [OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])], @@ -414,7 +411,7 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: async with AzureCliCredential() as credential: client = OpenAIChatClient(credential=credential) response = await client.get_response( - messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], + messages=[Message(role="user", contents=["How to create an Azure storage account using az cli?"])], options={ "max_tokens": 5000, "tools": OpenAIChatClient.get_mcp_tool( @@ -439,7 +436,7 @@ async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: client = OpenAIChatClient(credential=credential) response = await client.get_response( - messages=[Message(role="user", text="Calculate the sum of numbers from 1 to 10 using Python code.")], + messages=[Message(role="user", contents=["Calculate the sum of numbers from 1 to 10 using Python code."])], options={"tools": [OpenAIChatClient.get_code_interpreter_tool()]}, ) @@ -503,7 +500,7 @@ async def test_azure_openai_chat_client_tool_rich_content_image() -> None: client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: - messages = [Message(role="user", text="Call the get_test_image tool and describe what you see.")] + messages = [Message(role="user", contents=["Call the get_test_image tool and describe what you see."])] options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} if streaming: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 18eff3a54f..c012433b08 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -79,11 +79,11 @@ def test_supports_web_search_only() -> None: def test_init_prefers_openai_chat_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: - monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model_id") + monkeypatch.setenv("OPENAI_CHAT_COMPLETION_MODEL", "test_chat_model") open_ai_chat_completion = OpenAIChatCompletionClient() - assert open_ai_chat_completion.model == "test_chat_model_id" + assert open_ai_chat_completion.model == "test_chat_model" def test_init_validation_fail() -> None: @@ -92,12 +92,12 @@ def test_init_validation_fail() -> None: OpenAIChatCompletionClient(api_key="34523", model={"test": "dict"}) # type: ignore -def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None: +def test_init_model_constructor(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization - model_id = "test_model_id" - open_ai_chat_completion = OpenAIChatCompletionClient(model=model_id) + model = "test_model" + open_ai_chat_completion = OpenAIChatCompletionClient(model=model) - assert open_ai_chat_completion.model == model_id + assert open_ai_chat_completion.model == model assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) @@ -141,18 +141,18 @@ def test_init_base_url_from_settings_env() -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) -def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: +def test_init_with_empty_model(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient() @pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: - model_id = "test_model_id" + model = "test_model" with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient( - model=model_id, + model=model, ) @@ -196,7 +196,7 @@ async def test_content_filter_exception_handling( ) -> None: """Test that content filter errors are properly handled.""" client = OpenAIChatCompletionClient() - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] # Create a mock BadRequestError with content_filter code mock_response = MagicMock() @@ -237,6 +237,63 @@ def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None assert result["tools"] == [dict_tool] +def test_mcp_tool_dict_passed_through_to_chat_api(openai_unit_test_env: dict[str, str]) -> None: + """Test that MCP tool dicts are passed through unchanged by the chat client. + + The Chat Completions API does not support "type": "mcp" tools. MCP tools + should be used with the Responses API client instead. This test documents + that the chat client passes dict-based tools through without filtering, + so callers must use the correct client for MCP tools. + """ + client = OpenAIChatCompletionClient() + + mcp_tool = { + "type": "mcp", + "server_label": "Microsoft_Learn_MCP", + "server_url": "https://learn.microsoft.com/api/mcp", + } + + result = client._prepare_tools_for_openai(mcp_tool) + assert "tools" in result + assert len(result["tools"]) == 1 + # The chat client passes dict tools through unchanged, including unsupported types + assert result["tools"][0]["type"] == "mcp" + + +@pytest.mark.asyncio +async def test_mcp_tool_dict_causes_api_rejection(openai_unit_test_env: dict[str, str]) -> None: + """Test that MCP tool dicts passed to the Chat Completions API cause a rejection. + + The Chat Completions API only supports "type": "function" tools. + When an MCP tool dict reaches the API, it returns a 400 error. + This regression test for #4861 verifies the chat client does not + silently drop or transform MCP dicts, so callers get a clear error + rather than a silent no-op. + """ + client = OpenAIChatCompletionClient() + messages = [Message(role="user", contents=["test message"])] + + mcp_tool = { + "type": "mcp", + "server_label": "Microsoft_Learn_MCP", + "server_url": "https://learn.microsoft.com/api/mcp", + } + + mock_response = MagicMock() + mock_error = BadRequestError( + message="Invalid tool type: mcp", + response=mock_response, + body={"error": {"code": "invalid_request", "message": "Invalid tool type: mcp"}}, + ) + mock_error.code = "invalid_request" + + with ( + patch.object(client.client.chat.completions, "create", side_effect=mock_error), + pytest.raises(ChatClientException), + ): + await client._inner_get_response(messages=messages, options={"tools": mcp_tool}) # type: ignore + + def test_prepare_tools_with_single_function_tool( openai_unit_test_env: dict[str, str], ) -> None: @@ -274,7 +331,7 @@ def get_weather(location: str) -> str: async def test_exception_message_includes_original_error_details() -> None: """Test that exception messages include original error details in the new format.""" client = OpenAIChatCompletionClient(model="test-model", api_key="test-key") - messages = [Message(role="user", text="test message")] + messages = [Message(role="user", contents=["test message"])] mock_response = MagicMock() original_error_message = "Invalid API request format" @@ -1121,12 +1178,12 @@ def test_parse_text_with_refusal(openai_unit_test_env: dict[str, str]) -> None: assert message.contents[0].text == "I cannot provide that information." -def test_prepare_options_without_model_id(openai_unit_test_env: dict[str, str]) -> None: - """Test that prepare_options raises error when model_id is not set.""" +def test_prepare_options_without_model(openai_unit_test_env: dict[str, str]) -> None: + """Test that prepare_options raises error when model is not set.""" client = OpenAIChatCompletionClient() - client.model = None # Remove model_id + client.model = None # Remove model - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] with pytest.raises(ValueError, match="model must be a non-empty string"): client._prepare_options(messages, {}) @@ -1164,7 +1221,7 @@ def test_prepare_options_with_instructions( """Test that instructions are prepended as system message.""" client = OpenAIChatCompletionClient() - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] options = {"instructions": "You are a helpful assistant."} prepared_options = client._prepare_options(messages, options) @@ -1176,6 +1233,32 @@ def test_prepare_options_with_instructions( assert prepared_options["messages"][0]["content"] == "You are a helpful assistant." +def test_prepare_options_with_instructions_no_duplicate( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that duplicate system message from instructions is not added again. + + Regression test for https://github.com/microsoft/agent-framework/issues/5049 + """ + client = OpenAIChatCompletionClient() + + # Simulate messages that already contain the system instruction + messages = [ + Message(role="system", contents=["You are a helpful assistant."]), + Message(role="user", contents=["Hello"]), + ] + options = {"instructions": "You are a helpful assistant."} + + prepared_options = client._prepare_options(messages, options) + + # Should NOT duplicate the system message + assert "messages" in prepared_options + assert len(prepared_options["messages"]) == 2 + assert prepared_options["messages"][0]["role"] == "system" + assert prepared_options["messages"][0]["content"] == "You are a helpful assistant." + assert prepared_options["messages"][1]["role"] == "user" + + def test_prepare_message_with_author_name(openai_unit_test_env: dict[str, str]) -> None: """Test that author_name is included in prepared message.""" client = OpenAIChatCompletionClient() @@ -1333,7 +1416,7 @@ def test_tool_choice_required_with_function_name( """Test that tool_choice with required mode and function name is correctly prepared.""" client = OpenAIChatCompletionClient() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] options = { "tools": [get_weather], "tool_choice": {"mode": "required", "required_function_name": "get_weather"}, @@ -1351,7 +1434,7 @@ def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) """Test that response_format as dict is passed through directly.""" client = OpenAIChatCompletionClient() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] custom_format = { "type": "json_schema", "json_schema": {"name": "Test", "schema": {"type": "object"}}, @@ -1364,6 +1447,31 @@ def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) assert prepared_options["response_format"] == custom_format +def test_parse_response_with_dict_response_format(openai_unit_test_env: dict[str, str]) -> None: + """Chat completions should parse dict response_format values into response.value.""" + client = OpenAIChatCompletionClient() + response = client._parse_response_from_openai( + ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="gpt-4o-mini", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content='{"answer": "Hello"}'), + finish_reason="stop", + ) + ], + ), + options={"response_format": {"type": "object", "properties": {"answer": {"type": "string"}}}}, + ) + + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["answer"] == "Hello" + + def test_multiple_function_calls_in_single_message( openai_unit_test_env: dict[str, str], ) -> None: @@ -1395,7 +1503,7 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools( """Test that parallel_tool_calls is removed when no tools are present.""" client = OpenAIChatCompletionClient() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] options = {"allow_multiple_tool_calls": True} prepared_options = client._prepare_options(messages, options) @@ -1408,7 +1516,7 @@ def test_prepare_options_excludes_conversation_id(openai_unit_test_env: dict[str """Test that conversation_id is excluded from prepared options for chat completions.""" client = OpenAIChatCompletionClient() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] options = {"conversation_id": "12345", "temperature": 0.7} prepared_options = client._prepare_options(messages, options) @@ -1424,7 +1532,7 @@ async def test_streaming_exception_handling( ) -> None: """Test that streaming errors are properly handled.""" client = OpenAIChatCompletionClient() - messages = [Message(role="user", text="test")] + messages = [Message(role="user", contents=["test"])] # Create a mock error during streaming mock_error = Exception("Streaming error") @@ -1532,14 +1640,14 @@ async def test_integration_options( # Prepare test message if option_name.startswith("tools") or option_name.startswith("tool_choice"): # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", contents=["What is the weather in Seattle?"])] elif option_name.startswith("response_format"): # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) + messages = [Message(role="user", contents=["The weather in Seattle is sunny"])] + messages.append(Message(role="user", contents=["What is the weather in Seattle?"])) else: # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", contents=["Say 'Hello World' briefly."])] # Build options dict options: dict[str, Any] = {option_name: option_value} @@ -1578,12 +1686,10 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value + assert "seattle" in response.value["location"].lower() @pytest.mark.flaky @@ -1599,7 +1705,7 @@ async def test_integration_web_search() -> None: "messages": [ Message( role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + contents=["Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer."], ) ], "options": { @@ -1631,7 +1737,7 @@ async def test_integration_web_search() -> None: "messages": [ Message( role="user", - text="What is the current weather? Do not ask for my current location.", + contents=["What is the current weather? Do not ask for my current location."], ) ], "options": { diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py index f9edab227a..b650a3bd50 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py @@ -29,9 +29,7 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or ( - os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "") == "" and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), + or (os.getenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL", "") == "" and os.getenv("AZURE_OPENAI_MODEL", "") == ""), reason="No real Azure OpenAI endpoint or chat deployment provided; skipping integration tests.", ) @@ -43,9 +41,7 @@ def _with_azure_openai_debug() -> Any: try: return await func(*args, **kwargs) except Exception as exc: - model = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) + model = os.getenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "") api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" @@ -82,7 +78,7 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatCompletionClient(azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT")) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_COMPLETION_MODEL"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" @@ -93,7 +89,7 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatCompletionClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_COMPLETION_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -115,7 +111,7 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ client = OpenAIChatCompletionClient(credential=lambda: "token") - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_COMPLETION_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -123,34 +119,34 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ def test_init_falls_back_to_generic_azure_deployment_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL", raising=False) client = OpenAIChatCompletionClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) def test_init_does_not_fall_back_to_openai_chat_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) - monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model") + monkeypatch.delenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) + monkeypatch.setenv("OPENAI_CHAT_COMPLETION_MODEL", "test_chat_model") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatCompletionClient() def test_init_does_not_fall_back_to_openai_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("OPENAI_CHAT_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) + monkeypatch.delenv("OPENAI_CHAT_COMPLETION_MODEL", raising=False) monkeypatch.setenv("OPENAI_MODEL", "gpt-5") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatCompletionClient() @@ -199,14 +195,16 @@ async def test_azure_openai_chat_completion_client_response() -> None: messages = [ Message( role="user", - text=( - "Emily and David, two passionate scientists, met during a research expedition to Antarctica. " - "Bonded by their love for the natural world and shared curiosity, they uncovered a " - "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " - "of climate change." - ), + contents=[ + ( + "Emily and David, two passionate scientists, met during a research expedition to Antarctica. " + "Bonded by their love for the natural world and shared curiosity, they uncovered a " + "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " + "of climate change." + ) + ], ), - Message(role="user", text="who are Emily and David?"), + Message(role="user", contents=["who are Emily and David?"]), ] response = await client.get_response(messages=messages) @@ -227,7 +225,7 @@ async def test_azure_openai_chat_completion_client_response_tools() -> None: client = OpenAIChatCompletionClient(credential=credential) response = await client.get_response( - messages=[Message(role="user", text="who are Emily and David?")], + messages=[Message(role="user", contents=["who are Emily and David?"])], options={"tools": [get_story_text], "tool_choice": "auto"}, ) @@ -248,14 +246,16 @@ async def test_azure_openai_chat_completion_client_streaming() -> None: messages=[ Message( role="user", - text=( - "Emily and David, two passionate scientists, met during a research expedition to Antarctica. " - "Bonded by their love for the natural world and shared curiosity, they uncovered a " - "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " - "of climate change." - ), + contents=[ + ( + "Emily and David, two passionate scientists, met during a research expedition to " + "Antarctica. Bonded by their love for the natural world and shared curiosity, they " + "uncovered a groundbreaking phenomenon in glaciology that could potentially reshape our " + "understanding of climate change." + ) + ], ), - Message(role="user", text="who are Emily and David?"), + Message(role="user", contents=["who are Emily and David?"]), ], stream=True, ) @@ -281,7 +281,7 @@ async def test_azure_openai_chat_completion_client_streaming_tools() -> None: client = OpenAIChatCompletionClient(credential=credential) response = client.get_response( - messages=[Message(role="user", text="who are Emily and David?")], + messages=[Message(role="user", contents=["who are Emily and David?"])], stream=True, options={"tools": [get_story_text], "tool_choice": "auto"}, ) diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py index 76d11d1dbd..1f25762105 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py @@ -67,7 +67,7 @@ async def test_cmc( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response(messages=chat_history) @@ -86,7 +86,7 @@ async def test_cmc_chat_options( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response( @@ -107,7 +107,7 @@ async def test_cmc_no_fcc_in_response( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatCompletionClient() @@ -129,7 +129,7 @@ async def test_cmc_structured_output_no_fcc( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) # Define a mock response format class Test(BaseModel): @@ -151,7 +151,7 @@ async def test_scmc_chat_options( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) openai_chat_completion = OpenAIChatCompletionClient() async for msg in openai_chat_completion.get_response( @@ -177,7 +177,7 @@ async def test_cmc_general_exception( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) openai_chat_completion = OpenAIChatCompletionClient() with pytest.raises(ChatClientException): @@ -194,7 +194,7 @@ async def test_cmc_additional_properties( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response(messages=chat_history, options={"reasoning_effort": "low"}) @@ -232,7 +232,7 @@ async def test_get_streaming( stream = MagicMock(spec=AsyncStream) stream.__aiter__.return_value = [content1, content2] mock_create.return_value = stream - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatCompletionClient() @@ -272,7 +272,7 @@ async def test_get_streaming_singular( stream = MagicMock(spec=AsyncStream) stream.__aiter__.return_value = [content1, content2] mock_create.return_value = stream - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatCompletionClient() @@ -312,7 +312,7 @@ async def test_get_streaming_structured_output_no_fcc( stream = MagicMock(spec=AsyncStream) stream.__aiter__.return_value = [content1, content2] mock_create.return_value = stream - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) # Define a mock response format class Test(BaseModel): @@ -336,7 +336,7 @@ async def test_get_streaming_no_fcc_in_response( openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(Message(role="user", text="hello world")) + chat_history.append(Message(role="user", contents=["hello world"])) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatCompletionClient() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index cf2ff4f60f..1690c2b70e 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -7,6 +7,7 @@ import os from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework import SupportsGetEmbeddings from agent_framework.exceptions import SettingNotFoundError from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding @@ -44,6 +45,7 @@ def test_openai_construction_with_explicit_params() -> None: api_key="test-key", ) assert client.model == "text-embedding-3-small" + assert isinstance(client, SupportsGetEmbeddings) def test_raw_openai_embedding_client_init_uses_explicit_parameters() -> None: @@ -183,7 +185,7 @@ async def test_openai_base64_decoding(openai_unit_test_env: dict[str, str]) -> N assert abs(expected - actual) < 1e-6 -async def test_openai_error_when_no_model_id() -> None: +async def test_openai_error_when_no_model() -> None: client = OpenAIEmbeddingClient.__new__(OpenAIEmbeddingClient) client.model = None client.client = MagicMock() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py index 3cf62a064d..2d3c457bf6 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py @@ -19,10 +19,7 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or ( - os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" - and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), + or (os.getenv("AZURE_OPENAI_EMBEDDING_MODEL", "") == "" and os.getenv("AZURE_OPENAI_MODEL", "") == ""), reason="No real Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", ) @@ -34,9 +31,7 @@ def _with_azure_openai_debug() -> Any: try: return await func(*args, **kwargs) except Exception as exc: - model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) + model = os.getenv("AZURE_OPENAI_EMBEDDING_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "") api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" @@ -54,7 +49,7 @@ def _with_azure_openai_debug() -> Any: def _get_azure_embedding_deployment_name() -> str: - return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + return os.getenv("AZURE_OPENAI_EMBEDDING_MODEL") or os.environ["AZURE_OPENAI_MODEL"] def _create_azure_embedding_client( @@ -77,7 +72,7 @@ def _create_azure_embedding_client( def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: client = _create_azure_embedding_client() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -87,7 +82,7 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_embedding_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -95,34 +90,34 @@ def test_init_auto_detects_azure_embedding_env(azure_openai_unit_test_env: dict[ def test_init_falls_back_to_generic_azure_deployment_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False) client = OpenAIEmbeddingClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) def test_init_does_not_fall_back_to_openai_embedding_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIEmbeddingClient() def test_init_does_not_fall_back_to_openai_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) monkeypatch.setenv("OPENAI_MODEL", "gpt-5") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIEmbeddingClient() @@ -156,7 +151,7 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ client = OpenAIEmbeddingClient(credential=lambda: "token") - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -177,7 +172,7 @@ def test_init_with_credential_wraps_async_token_credential( client = OpenAIEmbeddingClient(credential=credential) assert isinstance(client.client, AsyncAzureOpenAI) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") @@ -185,7 +180,7 @@ def test_init_with_credential_wraps_async_token_credential( def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: client = _create_azure_embedding_client() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert client.api_version == "2024-10-21" diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py index f01f3700f7..86c85cc079 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py @@ -188,7 +188,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): Usage: workflow.run("Write a blog post about AI agents") """ - await self._handle_messages([Message(role="user", text=task)], ctx) + await self._handle_messages([Message(role="user", contents=[task])], ctx) @handler async def handle_message( @@ -205,7 +205,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): ctx: Workflow context Usage: - workflow.run(Message(role="user", text="Write a blog post about AI agents")) + workflow.run(Message(role="user", contents=["Write a blog post about AI agents"])) """ await self._handle_messages([task], ctx) @@ -224,8 +224,8 @@ class BaseGroupChatOrchestrator(Executor, ABC): ctx: Workflow context Usage: workflow.run([ - Message(role="user", text="Write a blog post about AI agents"), - Message(role="user", text="Make it engaging and informative.") + Message(role="user", contents=["Write a blog post about AI agents"]), + Message(role="user", contents=["Make it engaging and informative."]) ]) """ if not task: @@ -377,7 +377,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): Returns: Message with completion content """ - return Message(role="assistant", text=message, author_name=self._name) + return Message(role="assistant", contents=[message], author_name=self._name) # Participant routing (shared across all patterns) @@ -441,7 +441,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): # AgentExecutors receive simple message list messages: list[Message] = [] if additional_instruction: - messages.append(Message(role="user", text=additional_instruction)) + messages.append(Message(role="user", contents=[additional_instruction])) request = AgentExecutorRequest(messages=messages, should_respond=True) await ctx.send_message(request, target_id=target) await ctx.add_event( diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index a99e221409..4f1c2f832a 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -499,7 +499,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): ]) ) # Prepend instruction as system message - current_conversation.append(Message(role="user", text=instruction)) + current_conversation.append(Message(role="user", contents=[instruction])) retry_attempts = self._retry_attempts while True: @@ -515,7 +515,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): current_conversation = [ Message( role="user", - text=f"Your input could not be parsed due to an error: {ex}. Please try again.", + contents=[f"Your input could not be parsed due to an error: {ex}. Please try again."], ) ] diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index 4352a8af47..44c651df5a 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -39,7 +39,7 @@ from dataclasses import dataclass from typing import Any from agent_framework import Agent, SupportsAgentRun -from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware +from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination from agent_framework._sessions import AgentSession from agent_framework._tools import FunctionTool, tool from agent_framework._types import AgentResponse, Content, Message @@ -138,8 +138,6 @@ class _AutoHandoffMiddleware(FunctionMiddleware): await call_next() return - from agent_framework._middleware import MiddlewareTermination - # Short-circuit execution and provide deterministic response payload for the tool call. # Parse the result using the default parser to ensure in a form that can be passed directly to LLM APIs. context.result = FunctionTool.parse_result({ @@ -163,7 +161,7 @@ class HandoffAgentUserRequest: """Create a HandoffAgentUserRequest from a simple text response.""" messages: list[Message] = [] if isinstance(response, str): - messages.append(Message(role="user", text=response)) + messages.append(Message(role="user", contents=[response])) elif isinstance(response, Message): messages.append(response) elif isinstance(response, list): @@ -171,7 +169,7 @@ class HandoffAgentUserRequest: if isinstance(item, Message): messages.append(item) elif isinstance(item, str): - messages.append(Message(role="user", text=item)) + messages.append(Message(role="user", contents=[item])) else: raise TypeError("List items must be either str or Message instances") else: @@ -375,6 +373,7 @@ class HandoffAgentExecutor(AgentExecutor): description=agent.description, context_providers=agent.context_providers, middleware=agent.agent_middleware, + require_per_service_call_history_persistence=agent.require_per_service_call_history_persistence, default_options=cloned_options, # type: ignore[assignment] ) @@ -536,7 +535,7 @@ class HandoffAgentExecutor(AgentExecutor): # or a termination condition is met. # This allows the agent to perform long-running tasks without returning control # to the coordinator or user prematurely. - self._cache.extend([Message(role="user", text=self._autonomous_mode_prompt)]) + self._cache.extend([Message(role="user", contents=[self._autonomous_mode_prompt])]) self._autonomous_mode_turns += 1 await self._run_agent_and_emit(ctx) else: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index b887d86df3..80031cd726 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -604,14 +604,14 @@ class StandardMagenticManager(MagenticManagerBase): # Gather facts facts_user = Message( role="user", - text=self.task_ledger_facts_prompt.format(task=magentic_context.task), + contents=[self.task_ledger_facts_prompt.format(task=magentic_context.task)], ) facts_msg = await self._complete([*magentic_context.chat_history, facts_user]) # Create plan plan_user = Message( role="user", - text=self.task_ledger_plan_prompt.format(team=team_text), + contents=[self.task_ledger_plan_prompt.format(team=team_text)], ) plan_msg = await self._complete([*magentic_context.chat_history, facts_user, facts_msg, plan_user]) @@ -628,7 +628,7 @@ class StandardMagenticManager(MagenticManagerBase): facts=facts_msg.text, plan=plan_msg.text, ) - return Message(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME) + return Message(role="assistant", contents=[combined], author_name=MAGENTIC_MANAGER_NAME) async def replan(self, magentic_context: MagenticContext) -> Message: """Update facts and plan when stalling or looping has been detected.""" @@ -640,16 +640,18 @@ class StandardMagenticManager(MagenticManagerBase): # Update facts facts_update_user = Message( role="user", - text=self.task_ledger_facts_update_prompt.format( - task=magentic_context.task, old_facts=self.task_ledger.facts.text - ), + contents=[ + self.task_ledger_facts_update_prompt.format( + task=magentic_context.task, old_facts=self.task_ledger.facts.text + ) + ], ) updated_facts = await self._complete([*magentic_context.chat_history, facts_update_user]) # Update plan plan_update_user = Message( role="user", - text=self.task_ledger_plan_update_prompt.format(team=team_text), + contents=[self.task_ledger_plan_update_prompt.format(team=team_text)], ) updated_plan = await self._complete([ *magentic_context.chat_history, @@ -671,7 +673,7 @@ class StandardMagenticManager(MagenticManagerBase): facts=updated_facts.text, plan=updated_plan.text, ) - return Message(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME) + return Message(role="assistant", contents=[combined], author_name=MAGENTIC_MANAGER_NAME) async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: """Use the model to produce a JSON progress ledger based on the conversation so far. @@ -691,7 +693,7 @@ class StandardMagenticManager(MagenticManagerBase): team=team_text, names=names_csv, ) - user_message = Message(role="user", text=prompt) + user_message = Message(role="user", contents=[prompt]) # Include full context to help the model decide current stage, with small retry loop attempts = 0 @@ -718,12 +720,12 @@ class StandardMagenticManager(MagenticManagerBase): async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: """Ask the model to produce the final answer addressed to the user.""" prompt = self.final_answer_prompt.format(task=magentic_context.task) - user_message = Message(role="user", text=prompt) + user_message = Message(role="user", contents=[prompt]) response = await self._complete([*magentic_context.chat_history, user_message]) # Ensure role is assistant return Message( role="assistant", - text=response.text, + contents=[response.text], author_name=response.author_name or MAGENTIC_MANAGER_NAME, ) @@ -806,11 +808,11 @@ class MagenticPlanReviewResponse: def revise(feedback: str | list[str] | Message | list[Message]) -> "MagenticPlanReviewResponse": """Create a revision response with feedback.""" if isinstance(feedback, str): - feedback = [Message(role="user", text=feedback)] + feedback = [Message(role="user", contents=[feedback])] elif isinstance(feedback, Message): feedback = [feedback] elif isinstance(feedback, list): - feedback = [Message(role="user", text=item) if isinstance(item, str) else item for item in feedback] + feedback = [Message(role="user", contents=[item]) if isinstance(item, str) else item for item in feedback] return MagenticPlanReviewResponse(review=feedback) @@ -1120,7 +1122,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): # Add instruction to conversation (assistant guidance) instruction_msg = Message( role="assistant", - text=str(instruction), + contents=[str(instruction)], author_name=MAGENTIC_MANAGER_NAME, ) self._magentic_context.chat_history.append(instruction_msg) @@ -1232,7 +1234,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): *self._magentic_context.chat_history, Message( role="assistant", - text=f"Workflow terminated due to reaching maximum {limit_type} count.", + contents=[f"Workflow terminated due to reaching maximum {limit_type} count."], author_name=MAGENTIC_MANAGER_NAME, ), ]) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index 16950606dc..e78d1bef14 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -73,7 +73,7 @@ class AgentRequestInfoResponse: Returns: AgentRequestInfoResponse instance. """ - return AgentRequestInfoResponse(messages=[Message(role="user", text=text) for text in texts]) + return AgentRequestInfoResponse(messages=[Message(role="user", contents=[text]) for text in texts]) @staticmethod def approve() -> "AgentRequestInfoResponse": diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestrator_helpers.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestrator_helpers.py index 1e13e4969a..ca8d5daee3 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestrator_helpers.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestrator_helpers.py @@ -36,7 +36,7 @@ def clean_conversation_for_handoff(conversation: list[Message]) -> list[Message] msg_copy = Message( role=msg.role, - text=" ".join(text_parts), + contents=[" ".join(text_parts)], author_name=msg.author_name, additional_properties=dict(msg.additional_properties) if msg.additional_properties else None, ) @@ -66,6 +66,6 @@ def create_completion_message( message_text = text or f"Conversation {reason}." return Message( role="assistant", - text=message_text, + contents=[message_text], author_name=author_name, ) diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index e519086dbf..69fb60e4bb 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", ] [tool.uv] diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index 8712aae3fd..7d9a2bc534 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -32,7 +32,7 @@ class _FakeAgentExec(Executor): @handler async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None: - response = AgentResponse(messages=Message(role="assistant", text=self._reply_text)) + response = AgentResponse(messages=Message(role="assistant", contents=[self._reply_text])) full_conversation = list(request.messages) + list(response.messages) await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation)) diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 7550f820c7..2118de5ba7 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -49,7 +49,7 @@ class StubAgent(BaseAgent): return self._run_impl() async def _run_impl(self) -> AgentResponse: - response = Message(role="assistant", text=self._reply_text, author_name=self.name) + response = Message(role="assistant", contents=[self._reply_text], author_name=self.name) return AgentResponse(messages=[response]) async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]: @@ -89,10 +89,12 @@ class StubManagerAgent(Agent): messages=[ Message( role="assistant", - text=( - '{"terminate": false, "reason": "Selecting agent", ' - '"next_speaker": "agent", "final_message": null}' - ), + contents=[ + ( + '{"terminate": false, "reason": "Selecting agent", ' + '"next_speaker": "agent", "final_message": null}' + ) + ], author_name=self.name, ) ], @@ -110,10 +112,12 @@ class StubManagerAgent(Agent): messages=[ Message( role="assistant", - text=( - '{"terminate": true, "reason": "Task complete", ' - '"next_speaker": null, "final_message": "agent manager final"}' - ), + contents=[ + ( + '{"terminate": true, "reason": "Task complete", ' + '"next_speaker": null, "final_message": "agent manager final"}' + ) + ], author_name=self.name, ) ], @@ -141,12 +145,14 @@ class ConcatenatedJsonManagerAgent(Agent): messages=[ Message( role="assistant", - text=( - '{"terminate": false, "reason": "invalid candidate", ' - '"next_speaker": "unknown", "final_message": null} ' - '{"terminate": false, "reason": "pick known participant", ' - '"next_speaker": "agent", "final_message": null}' - ), + contents=[ + ( + '{"terminate": false, "reason": "invalid candidate", ' + '"next_speaker": "unknown", "final_message": null} ' + '{"terminate": false, "reason": "pick known participant", ' + '"next_speaker": "agent", "final_message": null}' + ) + ], author_name=self.name, ) ] @@ -156,10 +162,12 @@ class ConcatenatedJsonManagerAgent(Agent): messages=[ Message( role="assistant", - text=( - '{"terminate": true, "reason": "Task complete", ' - '"next_speaker": null, "final_message": "concatenated manager final"}' - ), + contents=[ + ( + '{"terminate": true, "reason": "Task complete", ' + '"next_speaker": null, "final_message": "concatenated manager final"}' + ) + ], author_name=self.name, ) ] @@ -189,7 +197,7 @@ class StubMagenticManager(MagenticManagerBase): self._round = 0 async def plan(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="plan", author_name="magentic_manager") + return Message(role="assistant", contents=["plan"], author_name="magentic_manager") async def replan(self, magentic_context: MagenticContext) -> Message: return await self.plan(magentic_context) @@ -215,7 +223,7 @@ class StubMagenticManager(MagenticManagerBase): ) async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: - return Message(role="assistant", text="final", author_name="magentic_manager") + return Message(role="assistant", contents=["final"], author_name="magentic_manager") async def test_group_chat_builder_basic_flow() -> None: @@ -258,8 +266,8 @@ async def test_group_chat_as_agent_accepts_conversation() -> None: agent = workflow.as_agent(name="group-chat-agent") conversation = [ - Message(role="user", text="kickoff", author_name="user"), - Message(role="assistant", text="noted", author_name="alpha"), + Message(role="user", contents=["kickoff"], author_name="user"), + Message(role="assistant", contents=["noted"], author_name="alpha"), ] response = await agent.run(conversation) @@ -549,7 +557,7 @@ class TestConversationHandling: async def test_handle_chat_message_input(self) -> None: """Test handling Message input directly.""" - task_message = Message(role="user", text="test message") + task_message = Message(role="user", contents=["test message"]) def selector(state: GroupChatState) -> str: # Verify the task message was preserved in conversation @@ -573,8 +581,8 @@ class TestConversationHandling: async def test_handle_conversation_list_input(self) -> None: """Test handling conversation list preserves context.""" conversation = [ - Message(role="system", text="system message"), - Message(role="user", text="user message"), + Message(role="system", contents=["system message"]), + Message(role="user", contents=["user message"]), ] def selector(state: GroupChatState) -> str: @@ -913,10 +921,12 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): messages=[ Message( role="assistant", - text=( - '{"terminate": false, "reason": "Selecting alpha", ' - '"next_speaker": "alpha", "final_message": null}' - ), + contents=[ + ( + '{"terminate": false, "reason": "Selecting alpha", ' + '"next_speaker": "alpha", "final_message": null}' + ) + ], author_name=self.name, ) ], @@ -933,10 +943,12 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): messages=[ Message( role="assistant", - text=( - '{"terminate": true, "reason": "Task complete", ' - '"next_speaker": null, "final_message": "dynamic manager final"}' - ), + contents=[ + ( + '{"terminate": true, "reason": "Task complete", ' + '"next_speaker": null, "final_message": "dynamic manager final"}' + ) + ], author_name=self.name, ) ], diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 5c594ed537..0687708b20 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -8,10 +8,11 @@ from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework import ( Agent, - BaseContextProvider, ChatResponse, ChatResponseUpdate, Content, + ContextProvider, + InMemoryHistoryProvider, Message, ResponseStream, WorkflowEvent, @@ -268,7 +269,7 @@ async def test_resume_keeps_prior_user_context_for_same_agent() -> None: second_events = await _drain( workflow.run( stream=True, - responses={first_request.request_id: [Message(role="user", text="Order 2939393")]}, + responses={first_request.request_id: [Message(role="user", contents=["Order 2939393"])]}, ) ) second_request = _latest_request_info_event(second_events) @@ -279,7 +280,7 @@ async def test_resume_keeps_prior_user_context_for_same_agent() -> None: third_events = await _drain( workflow.run( stream=True, - responses={second_request.request_id: [Message(role="user", text="It arrived broken and unusable.")]}, + responses={second_request.request_id: [Message(role="user", contents=["It arrived broken and unusable."])]}, ) ) third_request = _latest_request_info_event(third_events) @@ -369,7 +370,7 @@ async def test_tool_approval_responses_are_not_replayed_from_history() -> None: await _drain( workflow.run( stream=True, - responses={second_request.request_id: [Message(role="user", text="Thanks, what's next?")]}, + responses={second_request.request_id: [Message(role="user", contents=["Thanks, what's next?"])]}, ) ) @@ -678,7 +679,7 @@ async def test_handoff_resume_preserves_approved_tool_output_for_stateless_runs( await _drain( workflow.run( stream=True, - responses={order_request.request_id: [Message(role="user", text="Please continue with refund.")]}, + responses={order_request.request_id: [Message(role="user", contents=["Please continue with refund."])]}, ) ) @@ -695,6 +696,48 @@ def test_handoff_clone_disables_provider_side_storage() -> None: assert executor._agent.default_options.get("store") is False +async def test_handoff_clone_preserves_per_service_call_history_persistence() -> None: + """Handoff clones should keep per-service-call history persistence active for auto-handoff termination.""" + triage_history = InMemoryHistoryProvider() + triage = Agent( + id="triage", + name="triage", + client=MockChatClient(name="triage", handoff_to="specialist"), + context_providers=[triage_history], + require_per_service_call_history_persistence=True, + ) + specialist = Agent( + id="specialist", + name="specialist", + client=MockChatClient(name="specialist"), + default_options={"tool_choice": "none"}, + ) + + workflow = ( + HandoffBuilder(participants=[triage, specialist], termination_condition=lambda _: False) + .with_start_agent(triage) + .add_handoff(triage, [specialist]) + .add_handoff(specialist, [triage]) + .build() + ) + + await _drain(workflow.run("start", stream=True)) + + executor = workflow.executors[resolve_agent_id(triage)] + assert isinstance(executor, HandoffAgentExecutor) + assert executor._agent.require_per_service_call_history_persistence is True + + provider_state = executor._session.state[triage_history.source_id] + stored_messages = await triage_history.get_messages( + executor._session.session_id, + state=provider_state, + ) + + assert [message.role for message in stored_messages] == ["user", "assistant"] + assert any(content.type == "function_call" for content in stored_messages[-1].contents) + assert all(message.role != "tool" for message in stored_messages) + + async def test_handoff_clears_stale_service_session_id_before_run() -> None: """Stale service session IDs must be dropped before each handoff agent turn.""" triage = MockHandoffAgent(name="triage", handoff_to="specialist") @@ -724,7 +767,7 @@ def test_clean_conversation_for_handoff_keeps_text_only_history() -> None: ) conversation = [ - Message(role="user", text="My order arrived damaged."), + Message(role="user", contents=["My order arrived damaged."]), Message( role="assistant", contents=[ @@ -890,7 +933,7 @@ async def test_handoff_async_termination_condition() -> None: events = await _drain( workflow.run( - stream=True, responses={requests[-1].request_id: [Message(role="user", text="Second user message")]} + stream=True, responses={requests[-1].request_id: [Message(role="user", contents=["Second user message"])]} ) ) outputs = [ev for ev in events if ev.type == "output"] @@ -968,7 +1011,7 @@ async def test_tool_choice_preserved_from_agent_config(): if options: recorded_tool_choices.append(options.get("tool_choice")) return ChatResponse( - messages=[Message(role="assistant", text="Response")], + messages=[Message(role="assistant", contents=["Response"])], response_id="test_response", ) @@ -997,7 +1040,7 @@ async def test_context_provider_preserved_during_handoff(): # Track whether context provider methods were called provider_calls: list[str] = [] - class TestContextProvider(BaseContextProvider): + class TestContextProvider(ContextProvider): """A test context provider that tracks its invocations.""" def __init__(self) -> None: diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index 1857a16ee4..b87de8c6f4 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -585,7 +585,7 @@ async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ captured.append( Message( role=ev.data.role or "assistant", - text=ev.data.text or "", + contents=[ev.data.text or ""], author_name=ev.data.author_name, ) ) diff --git a/python/packages/orchestrations/tests/test_orchestration_request_info.py b/python/packages/orchestrations/tests/test_orchestration_request_info.py index d618efcbf1..b7b266073b 100644 --- a/python/packages/orchestrations/tests/test_orchestration_request_info.py +++ b/python/packages/orchestrations/tests/test_orchestration_request_info.py @@ -72,7 +72,7 @@ class TestAgentRequestInfoResponse: def test_create_response_with_messages(self): """Test creating an AgentRequestInfoResponse with messages.""" - messages = [Message(role="user", text="Additional info")] + messages = [Message(role="user", contents=["Additional info"])] response = AgentRequestInfoResponse(messages=messages) assert response.messages == messages @@ -80,8 +80,8 @@ class TestAgentRequestInfoResponse: def test_from_messages_factory(self): """Test creating response from Message list.""" messages = [ - Message(role="user", text="Message 1"), - Message(role="user", text="Message 2"), + Message(role="user", contents=["Message 1"]), + Message(role="user", contents=["Message 2"]), ] response = AgentRequestInfoResponse.from_messages(messages) @@ -113,7 +113,7 @@ class TestAgentRequestInfoExecutor: """Test that request_info handler calls ctx.request_info.""" executor = AgentRequestInfoExecutor(id="test_executor") - agent_response = AgentResponse(messages=[Message(role="assistant", text="Agent response")]) + agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Agent response"])]) agent_response = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, @@ -132,7 +132,7 @@ class TestAgentRequestInfoExecutor: """Test response handler when user provides additional messages.""" executor = AgentRequestInfoExecutor(id="test_executor") - agent_response = AgentResponse(messages=[Message(role="assistant", text="Original")]) + agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Original"])]) original_request = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, @@ -159,7 +159,7 @@ class TestAgentRequestInfoExecutor: """Test response handler when user approves (no additional messages).""" executor = AgentRequestInfoExecutor(id="test_executor") - agent_response = AgentResponse(messages=[Message(role="assistant", text="Original")]) + agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Original"])]) original_request = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, @@ -212,10 +212,10 @@ class _TestAgent: """Dummy run method.""" if stream: return self._run_stream_impl() - return AgentResponse(messages=[Message(role="assistant", text="Test response")]) + return AgentResponse(messages=[Message(role="assistant", contents=["Test response"])]) async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]: - yield AgentResponseUpdate(messages=[Message(role="assistant", text="Test response stream")]) + yield AgentResponseUpdate(messages=[Message(role="assistant", contents=["Test response stream"])]) def create_session(self, **kwargs: Any) -> AgentSession: """Creates a new conversation session for the agent.""" diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index 2b1f9b7984..a802cd9615 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -54,12 +54,12 @@ Add Purview when you need to: ```python import asyncio from agent_framework import Agent, Message, Role -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings from azure.identity import InteractiveBrowserCredential async def main(): - client = AzureOpenAIChatClient() # uses environment for endpoint + deployment + client = OpenAIChatCompletionClient() # uses environment for endpoint + deployment purview_middleware = PurviewPolicyMiddleware( credential=InteractiveBrowserCredential(), @@ -219,12 +219,12 @@ Use the agent middleware when you already have / want the full agent pipeline: ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() -client = AzureOpenAIChatClient() +client = OpenAIChatCompletionClient() agent = Agent( client=client, @@ -238,15 +238,15 @@ Use the chat middleware when you attach directly to a chat client (e.g. minimal ```python import os from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.microsoft import PurviewChatPolicyMiddleware, PurviewSettings from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() -client = AzureOpenAIChatClient( - deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], +client = OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_MODEL"], + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=credential, middleware=[ PurviewChatPolicyMiddleware(credential, PurviewSettings(app_name="My App (Chat)")) diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py index 0b7442b47d..cf98294f33 100644 --- a/python/packages/purview/agent_framework_purview/_middleware.py +++ b/python/packages/purview/agent_framework_purview/_middleware.py @@ -82,10 +82,13 @@ class PurviewPolicyMiddleware(AgentMiddleware): if should_block_prompt: from agent_framework import AgentResponse, Message + msg = self._settings.get("blocked_prompt_message", None) or "Prompt blocked by policy" + context.result = AgentResponse( messages=[ Message( - role="system", text=self._settings.get("blocked_prompt_message", "Prompt blocked by policy") + role="system", + contents=[msg], ) ] ) @@ -119,11 +122,13 @@ class PurviewPolicyMiddleware(AgentMiddleware): if should_block_response: from agent_framework import AgentResponse, Message + msg = self._settings.get("blocked_response_message", None) or "Response blocked by policy" + context.result = AgentResponse( messages=[ Message( role="system", - text=self._settings.get("blocked_response_message", "Response blocked by policy"), + contents=[msg], ) ] ) @@ -189,7 +194,8 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): from agent_framework import ChatResponse, Message blocked_message = Message( - role="system", text=self._settings.get("blocked_prompt_message", "Prompt blocked by policy") + role="system", + contents=[self._settings.get("blocked_prompt_message", None) or "Prompt blocked by policy"], ) context.result = ChatResponse(messages=[blocked_message]) raise MiddlewareTermination @@ -224,7 +230,9 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): blocked_message = Message( role="system", - text=self._settings.get("blocked_response_message", "Response blocked by policy"), + contents=[ + self._settings.get("blocked_response_message", None) or "Response blocked by policy" + ], ) context.result = ChatResponse(messages=[blocked_message]) else: diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 183de4570c..ad63be4448 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases" urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/purview/tests/purview/test_chat_middleware.py b/python/packages/purview/tests/purview/test_chat_middleware.py index e740cdabc6..4134b2f0f2 100644 --- a/python/packages/purview/tests/purview/test_chat_middleware.py +++ b/python/packages/purview/tests/purview/test_chat_middleware.py @@ -37,7 +37,7 @@ class TestPurviewChatPolicyMiddleware: client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - return ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) + return ChatContext(client=client, messages=[Message(role="user", contents=["Hello"])], options=chat_options) async def test_initialization(self, middleware: PurviewChatPolicyMiddleware) -> None: assert middleware._client is not None @@ -55,7 +55,7 @@ class TestPurviewChatPolicyMiddleware: class Result: def __init__(self): - self.messages = [Message(role="assistant", text="Hi there")] + self.messages = [Message(role="assistant", contents=["Hi there"])] chat_context.result = Result() @@ -91,7 +91,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: class Result: def __init__(self): - self.messages = [Message(role="assistant", text="Sensitive output")] # pragma: no cover + self.messages = [Message(role="assistant", contents=["Sensitive output"])] # pragma: no cover chat_context.result = Result() @@ -108,7 +108,7 @@ class TestPurviewChatPolicyMiddleware: chat_options.model = "test-model" streaming_context = ChatContext( client=client, - messages=[Message(role="user", text="Hello")], + messages=[Message(role="user", contents=["Hello"])], options=chat_options, stream=True, ) @@ -140,7 +140,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="Response")] + result.messages = [Message(role="assistant", contents=["Response"])] chat_context.result = result await middleware.process(chat_context, mock_next) @@ -164,7 +164,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="Response")] + result.messages = [Message(role="assistant", contents=["Response"])] chat_context.result = result await middleware.process(chat_context, mock_next) @@ -187,7 +187,7 @@ class TestPurviewChatPolicyMiddleware: client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) + context = ChatContext(client=client, messages=[Message(role="user", contents=["Hello"])], options=chat_options) async def mock_process_messages(*args, **kwargs): raise PurviewPaymentRequiredError("Payment required") @@ -211,7 +211,7 @@ class TestPurviewChatPolicyMiddleware: client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) + context = ChatContext(client=client, messages=[Message(role="user", contents=["Hello"])], options=chat_options) call_count = 0 @@ -226,7 +226,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="OK")] + result.messages = [Message(role="assistant", contents=["OK"])] context.result = result with pytest.raises(PurviewPaymentRequiredError): @@ -242,7 +242,7 @@ class TestPurviewChatPolicyMiddleware: client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) + context = ChatContext(client=client, messages=[Message(role="user", contents=["Hello"])], options=chat_options) async def mock_process_messages(*args, **kwargs): raise PurviewPaymentRequiredError("Payment required") @@ -251,7 +251,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="Response")] + result.messages = [Message(role="assistant", contents=["Response"])] context.result = result # Should not raise, just log @@ -282,7 +282,7 @@ class TestPurviewChatPolicyMiddleware: client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) + context = ChatContext(client=client, messages=[Message(role="user", contents=["Hello"])], options=chat_options) async def mock_process_messages(*args, **kwargs): raise ValueError("Some error") @@ -291,7 +291,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="Response")] + result.messages = [Message(role="assistant", contents=["Response"])] context.result = result # Should not raise, just log @@ -309,7 +309,7 @@ class TestPurviewChatPolicyMiddleware: client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) + context = ChatContext(client=client, messages=[Message(role="user", contents=["Hello"])], options=chat_options) with patch.object(middleware._processor, "process_messages", side_effect=ValueError("boom")): @@ -329,7 +329,7 @@ class TestPurviewChatPolicyMiddleware: client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) + context = ChatContext(client=client, messages=[Message(role="user", contents=["Hello"])], options=chat_options) call_count = 0 @@ -344,7 +344,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="OK")] + result.messages = [Message(role="assistant", contents=["OK"])] context.result = result with pytest.raises(ValueError, match="post"): @@ -355,7 +355,7 @@ class TestPurviewChatPolicyMiddleware: ) -> None: """Test that session_id is extracted from context.options['conversation_id'].""" chat_client = DummyChatClient() - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] options = {"conversation_id": "conv-123", "model": "test-model"} context = ChatContext(client=chat_client, messages=messages, options=options) @@ -363,7 +363,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="Hi")] + result.messages = [Message(role="assistant", contents=["Hi"])] context.result = result await middleware.process(context, mock_next) @@ -377,14 +377,14 @@ class TestPurviewChatPolicyMiddleware: ) -> None: """Test that session_id is None when options don't contain conversation_id.""" chat_client = DummyChatClient() - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] context = ChatContext(client=chat_client, messages=messages, options=None) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="Hi")] + result.messages = [Message(role="assistant", contents=["Hi"])] context.result = result await middleware.process(context, mock_next) @@ -395,7 +395,7 @@ class TestPurviewChatPolicyMiddleware: async def test_chat_middleware_session_id_used_in_post_check(self, middleware: PurviewChatPolicyMiddleware) -> None: """Test that session_id is passed to post-check process_messages call.""" chat_client = DummyChatClient() - messages = [Message(role="user", text="Hello")] + messages = [Message(role="user", contents=["Hello"])] options = {"conversation_id": "conv-999"} context = ChatContext(client=chat_client, messages=messages, options=options) @@ -403,7 +403,7 @@ class TestPurviewChatPolicyMiddleware: async def mock_next() -> None: result = MagicMock() - result.messages = [Message(role="assistant", text="Response")] + result.messages = [Message(role="assistant", contents=["Response"])] context.result = result await middleware.process(context, mock_next) diff --git a/python/packages/purview/tests/purview/test_middleware.py b/python/packages/purview/tests/purview/test_middleware.py index b49b5d46a0..274e4878b1 100644 --- a/python/packages/purview/tests/purview/test_middleware.py +++ b/python/packages/purview/tests/purview/test_middleware.py @@ -50,7 +50,7 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test middleware allows prompt that passes policy check.""" - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello, how are you?")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello, how are you?"])]) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")): next_called = False @@ -58,7 +58,7 @@ class TestPurviewPolicyMiddleware: async def mock_next() -> None: nonlocal next_called next_called = True - context.result = AgentResponse(messages=[Message(role="assistant", text="I'm good, thanks!")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["I'm good, thanks!"])]) await middleware.process(context, mock_next) @@ -69,7 +69,7 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test middleware blocks prompt that violates policy.""" - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Sensitive information")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Sensitive information"])]) with patch.object(middleware._processor, "process_messages", return_value=(True, "user-123")): next_called = False @@ -89,7 +89,7 @@ class TestPurviewPolicyMiddleware: async def test_middleware_checks_response(self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock) -> None: """Test middleware checks agent response for policy violations.""" - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])]) call_count = 0 @@ -103,7 +103,7 @@ class TestPurviewPolicyMiddleware: async def mock_next() -> None: context.result = AgentResponse( - messages=[Message(role="assistant", text="Here's some sensitive information")] + messages=[Message(role="assistant", contents=["Here's some sensitive information"])] ) await middleware.process(context, mock_next) @@ -121,7 +121,7 @@ class TestPurviewPolicyMiddleware: # Set ignore_exceptions to True so AttributeError is caught and logged middleware._settings["ignore_exceptions"] = True - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])]) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")): @@ -138,12 +138,12 @@ class TestPurviewPolicyMiddleware: """Test middleware passes correct activity type to processor.""" from agent_framework_purview._models import Activity - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Test"])]) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_process: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) await middleware.process(context, mock_next) @@ -157,13 +157,13 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test that streaming results skip post-check evaluation.""" - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])]) context.stream = True with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="streaming")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["streaming"])]) await middleware.process(context, mock_next) @@ -175,7 +175,7 @@ class TestPurviewPolicyMiddleware: """Test that 402 in pre-check is raised when ignore_payment_required=False.""" from agent_framework_purview._exceptions import PurviewPaymentRequiredError - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])]) with patch.object( middleware._processor, @@ -195,7 +195,7 @@ class TestPurviewPolicyMiddleware: """Test that 402 in post-check is raised when ignore_payment_required=False.""" from agent_framework_purview._exceptions import PurviewPaymentRequiredError - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])]) call_count = 0 @@ -209,7 +209,7 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=side_effect): async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="OK")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["OK"])]) with pytest.raises(PurviewPaymentRequiredError): await middleware.process(context, mock_next) @@ -220,7 +220,7 @@ class TestPurviewPolicyMiddleware: """Test that post-check exceptions are propagated when ignore_exceptions=False.""" middleware._settings["ignore_exceptions"] = False - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])]) call_count = 0 @@ -234,7 +234,7 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=side_effect): async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="OK")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["OK"])]) with pytest.raises(ValueError, match="Post-check blew up"): await middleware.process(context, mock_next) @@ -246,14 +246,14 @@ class TestPurviewPolicyMiddleware: # Set ignore_exceptions to True middleware._settings["ignore_exceptions"] = True - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Test"])]) with patch.object( middleware._processor, "process_messages", side_effect=Exception("Pre-check error") ) as mock_process: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) await middleware.process(context, mock_next) @@ -269,7 +269,7 @@ class TestPurviewPolicyMiddleware: # Set ignore_exceptions to True middleware._settings["ignore_exceptions"] = True - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Test"])]) call_count = 0 @@ -283,7 +283,7 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) await middleware.process(context, mock_next) @@ -300,7 +300,7 @@ class TestPurviewPolicyMiddleware: mock_agent = MagicMock() mock_agent.name = "test-agent" - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Test"])]) # Mock processor to raise an exception async def mock_process_messages(*args, **kwargs): @@ -309,7 +309,7 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): async def mock_next(): - context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) # Should not raise, just log await middleware.process(context, mock_next) @@ -324,7 +324,7 @@ class TestPurviewPolicyMiddleware: mock_agent = MagicMock() mock_agent.name = "test-agent" - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Test"])]) # Mock processor to raise an exception async def mock_process_messages(*args, **kwargs): @@ -344,12 +344,12 @@ class TestPurviewPolicyMiddleware: ) -> None: """Test that session_id is extracted from session.service_session_id.""" session = AgentSession(service_session_id="thread-123") - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")], session=session) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])], session=session) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Hi"])]) await middleware.process(context, mock_next) @@ -361,13 +361,13 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test that session_id is extracted from message.additional_properties['conversation_id'].""" - messages = [Message(role="user", text="Hello", additional_properties={"conversation_id": "conv-456"})] + messages = [Message(role="user", contents=["Hello"], additional_properties={"conversation_id": "conv-456"})] context = AgentContext(agent=mock_agent, messages=messages) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Hi"])]) await middleware.process(context, mock_next) @@ -380,13 +380,13 @@ class TestPurviewPolicyMiddleware: ) -> None: """Test that session.service_session_id takes precedence over message conversation_id.""" session = AgentSession(service_session_id="thread-789") - messages = [Message(role="user", text="Hello", additional_properties={"conversation_id": "conv-456"})] + messages = [Message(role="user", contents=["Hello"], additional_properties={"conversation_id": "conv-456"})] context = AgentContext(agent=mock_agent, messages=messages, session=session) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Hi"])]) await middleware.process(context, mock_next) @@ -397,12 +397,12 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test that session_id is None when no session or conversation_id is available.""" - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])]) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Hi"])]) await middleware.process(context, mock_next) @@ -414,12 +414,12 @@ class TestPurviewPolicyMiddleware: ) -> None: """Test that session_id is passed to post-check process_messages call.""" session = AgentSession(service_session_id="thread-999") - context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")], session=session) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", contents=["Hello"])], session=session) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: async def mock_next() -> None: - context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) + context.result = AgentResponse(messages=[Message(role="assistant", contents=["Response"])]) await middleware.process(context, mock_next) diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 7e70072508..285fb338d8 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -83,8 +83,8 @@ class TestScopedContentProcessor: async def test_process_messages_with_defaults(self, processor: ScopedContentProcessor) -> None: """Test process_messages with settings that have defaults.""" messages = [ - Message(role="user", text="Hello"), - Message(role="assistant", text="Hi there"), + Message(role="user", contents=["Hello"]), + Message(role="assistant", contents=["Hi there"]), ] with patch.object(processor, "_map_messages", return_value=([], None)) as mock_map: @@ -98,7 +98,7 @@ class TestScopedContentProcessor: self, processor: ScopedContentProcessor, process_content_request_factory ) -> None: """Test process_messages returns True when content should be blocked.""" - messages = [Message(role="user", text="Sensitive content")] + messages = [Message(role="user", contents=["Sensitive content"])] mock_request = process_content_request_factory("Sensitive content") @@ -122,7 +122,7 @@ class TestScopedContentProcessor: messages = [ Message( role="user", - text="Test message", + contents=["Test message"], message_id="msg-123", author_name="12345678-1234-1234-1234-123456789012", ), @@ -139,7 +139,7 @@ class TestScopedContentProcessor: """Test _map_messages gets token info when settings lack some defaults.""" settings = PurviewSettings(app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012") processor = ScopedContentProcessor(mock_client, settings) - messages = [Message(role="user", text="Test", message_id="msg-123")] + messages = [Message(role="user", contents=["Test"], message_id="msg-123")] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -156,7 +156,7 @@ class TestScopedContentProcessor: return_value={"user_id": "test-user", "client_id": "test-client"} ) - messages = [Message(role="user", text="Test", message_id="msg-123")] + messages = [Message(role="user", contents=["Test"], message_id="msg-123")] with pytest.raises(ValueError, match="Tenant id required"): await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -333,7 +333,7 @@ class TestScopedContentProcessor: messages = [ Message( role="user", - text="Test message", + contents=["Test message"], additional_properties={"user_id": "22345678-1234-1234-1234-123456789012"}, ), ] @@ -355,7 +355,7 @@ class TestScopedContentProcessor: ) processor = ScopedContentProcessor(mock_client, settings) - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] requests, user_id = await processor._map_messages( messages, Activity.UPLOAD_TEXT, provided_user_id="32345678-1234-1234-1234-123456789012" @@ -376,7 +376,7 @@ class TestScopedContentProcessor: ) processor = ScopedContentProcessor(mock_client, settings) - messages = [Message(role="user", text="Test message")] + messages = [Message(role="user", contents=["Test message"])] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -479,7 +479,7 @@ class TestUserIdResolution: settings = PurviewSettings(app_name="Test App") # No tenant_id or app_location processor = ScopedContentProcessor(mock_client, settings) - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -495,7 +495,7 @@ class TestUserIdResolution: messages = [ Message( role="user", - text="Test", + contents=["Test"], additional_properties={"user_id": "22222222-2222-2222-2222-222222222222"}, ) ] @@ -515,7 +515,7 @@ class TestUserIdResolution: messages = [ Message( role="user", - text="Test", + contents=["Test"], author_name="33333333-3333-3333-3333-333333333333", ) ] @@ -533,7 +533,7 @@ class TestUserIdResolution: messages = [ Message( role="user", - text="Test", + contents=["Test"], author_name="John Doe", # Not a GUID ) ] @@ -550,7 +550,7 @@ class TestUserIdResolution: """Test provided_user_id parameter is used as last resort.""" processor = ScopedContentProcessor(mock_client, settings) - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] requests, user_id = await processor._map_messages( messages, Activity.UPLOAD_TEXT, provided_user_id="44444444-4444-4444-4444-444444444444" @@ -562,7 +562,7 @@ class TestUserIdResolution: """Test invalid provided_user_id is ignored.""" processor = ScopedContentProcessor(mock_client, settings) - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid") @@ -575,10 +575,12 @@ class TestUserIdResolution: messages = [ Message( - role="user", text="First", additional_properties={"user_id": "55555555-5555-5555-5555-555555555555"} + role="user", + contents=["First"], + additional_properties={"user_id": "55555555-5555-5555-5555-555555555555"}, ), - Message(role="assistant", text="Response"), - Message(role="user", text="Second"), + Message(role="assistant", contents=["Response"]), + Message(role="user", contents=["Second"]), ] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -594,14 +596,16 @@ class TestUserIdResolution: processor = ScopedContentProcessor(mock_client, settings) messages = [ - Message(role="user", text="First", author_name="Not a GUID"), + Message(role="user", contents=["First"], author_name="Not a GUID"), Message( role="assistant", - text="Response", + contents=["Response"], additional_properties={"user_id": "66666666-6666-6666-6666-666666666666"}, ), Message( - role="user", text="Third", additional_properties={"user_id": "77777777-7777-7777-7777-777777777777"} + role="user", + contents=["Third"], + additional_properties={"user_id": "77777777-7777-7777-7777-777777777777"}, ), ] @@ -653,7 +657,7 @@ class TestScopedContentProcessorCaching: scope_identifier="scope-123", scopes=[] ) - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012") @@ -675,7 +679,7 @@ class TestScopedContentProcessorCaching: mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required") - messages = [Message(role="user", text="Test")] + messages = [Message(role="user", contents=["Test"])] with pytest.raises(PurviewPaymentRequiredError): await processor.process_messages( diff --git a/python/packages/redis/agent_framework_redis/_context_provider.py b/python/packages/redis/agent_framework_redis/_context_provider.py index 98d5d9917f..4c102f0187 100644 --- a/python/packages/redis/agent_framework_redis/_context_provider.py +++ b/python/packages/redis/agent_framework_redis/_context_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Redis context provider using BaseContextProvider. +"""New-pattern Redis context provider using ContextProvider. This module provides ``RedisContextProvider``, built on the new -:class:`BaseContextProvider` hooks pattern. +:class:`ContextProvider` hooks pattern. """ from __future__ import annotations @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal import numpy as np from agent_framework import Message -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext +from agent_framework._sessions import AgentSession, ContextProvider, SessionContext from agent_framework.exceptions import ( AgentException, IntegrationInvalidRequestException, @@ -41,8 +41,8 @@ if TYPE_CHECKING: from agent_framework._agents import SupportsAgentRun -class RedisContextProvider(BaseContextProvider): - """Redis context provider using the new BaseContextProvider hooks pattern. +class RedisContextProvider(ContextProvider): + """Redis context provider using the new ContextProvider hooks pattern. Stores context in Redis and retrieves scoped context via full-text or optional hybrid vector search. @@ -136,7 +136,7 @@ class RedisContextProvider(BaseContextProvider): if line_separated_memories: context.extend_messages( self.source_id, - [Message(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")], + [Message(role="user", contents=[f"{self.context_prompt}\n{line_separated_memories}"])], ) @override diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index be2db098b8..dbdc358a93 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Redis history provider using BaseHistoryProvider. +"""New-pattern Redis history provider using HistoryProvider. This module provides ``RedisHistoryProvider``, built on the new -:class:`BaseHistoryProvider` hooks pattern. +:class:`HistoryProvider` hooks pattern. """ from __future__ import annotations @@ -13,12 +13,12 @@ from typing import Any, ClassVar import redis.asyncio as redis from agent_framework import Message -from agent_framework._sessions import BaseHistoryProvider +from agent_framework._sessions import HistoryProvider from redis.credentials import CredentialProvider -class RedisHistoryProvider(BaseHistoryProvider): - """Redis-backed history provider using the new BaseHistoryProvider hooks pattern. +class RedisHistoryProvider(HistoryProvider): + """Redis-backed history provider using the new HistoryProvider hooks pattern. Stores conversation history in Redis Lists, with each session isolated by a unique Redis key. diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 9cb248ccf9..0feec5bd23 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260330" +version = "1.0.0b260402" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc6", + "agent-framework-core>=1.0.0,<2", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/packages/redis/tests/test_providers.py b/python/packages/redis/tests/test_providers.py index dd0ff51cd8..54587a55e1 100644 --- a/python/packages/redis/tests/test_providers.py +++ b/python/packages/redis/tests/test_providers.py @@ -475,7 +475,7 @@ class TestRedisHistoryProviderClear: class TestRedisHistoryProviderBeforeAfterRun: - """Test before_run/after_run integration via BaseHistoryProvider defaults.""" + """Test before_run/after_run integration via HistoryProvider defaults.""" async def test_before_run_loads_history(self, mock_redis_client: MagicMock): msg = Message(role="user", contents=["old msg"]) diff --git a/python/pyproject.toml b/python/pyproject.toml index a554ce97f1..24af13b940 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc6" +version = "1.0.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -23,29 +23,29 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.0.0rc6", + "agent-framework-core[all]==1.0.0", ] [dependency-groups] dev = [ - "uv==0.10.9", + "uv==0.11.3", "flit==3.12.0", - "ruff==0.15.5", + "ruff==0.15.8", "pytest==9.0.2", "pytest-asyncio==1.3.0", - "pytest-cov==7.0.0", + "pytest-cov==7.1.0", "pytest-xdist[psutil]==3.8.0", "pytest-timeout==2.4.0", "pytest-retry==1.7.0", - "mypy==1.19.1", + "mypy==1.20.0", "pyright==1.1.408", - "mcp[ws]>=1.24.0,<2", - "opentelemetry-sdk>=1.39.0,<2", + "mcp[ws]==1.26.0", + "opentelemetry-sdk==1.40.0", #tasks "poethepoet==0.42.1", - "rich==13.7.1", - "tomli==2.4.0", - "prek==0.3.4", + "rich>=13.7.1,<15.0.0", + "tomli==2.4.1", + "prek==0.3.8", ] [tool.uv] @@ -70,7 +70,6 @@ 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-azure-ai = { workspace = true } agent-framework-azurefunctions = { workspace = true } agent-framework-bedrock = { workspace = true } agent-framework-chatkit = { workspace = true } @@ -187,7 +186,6 @@ executionEnvironments = [ { root = "packages/ag-ui/tests", reportPrivateUsage = "none" }, { root = "packages/anthropic/tests", reportPrivateUsage = "none" }, { root = "packages/azure-ai-search/tests", reportPrivateUsage = "none" }, - { root = "packages/azure-ai/tests", reportPrivateUsage = "none" }, { root = "packages/azure-cosmos/tests", reportPrivateUsage = "none" }, { root = "packages/azurefunctions/tests", reportPrivateUsage = "none" }, { root = "packages/bedrock/tests", reportPrivateUsage = "none" }, @@ -367,7 +365,6 @@ sequence = [ { ref = "install" }, { ref = "check" }, { ref = "typing" }, - { ref = "test" }, ] [tool.poe.tasks.add-dependency-to-project] diff --git a/python/samples/01-get-started/04_memory.py b/python/samples/01-get-started/04_memory.py index 763a872ca7..7e0b1e2d5f 100644 --- a/python/samples/01-get-started/04_memory.py +++ b/python/samples/01-get-started/04_memory.py @@ -3,7 +3,7 @@ import asyncio from typing import Any -from agent_framework import Agent, AgentSession, BaseContextProvider, SessionContext +from agent_framework import Agent, AgentSession, ContextProvider, SessionContext from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential @@ -17,7 +17,7 @@ responses — the name persists across turns via the session. # -class UserMemoryProvider(BaseContextProvider): +class UserMemoryProvider(ContextProvider): """A context provider that remembers user info in session state.""" DEFAULT_SOURCE_ID = "user_memory" diff --git a/python/samples/01-get-started/README.md b/python/samples/01-get-started/README.md index 7d696ba528..9ecfdf08c9 100644 --- a/python/samples/01-get-started/README.md +++ b/python/samples/01-get-started/README.md @@ -6,7 +6,14 @@ concepts of **Agent Framework** one step at a time. ## Prerequisites ```bash -pip install agent-framework --pre +pip install agent-framework +``` + +Set the required environment variables: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint" +export FOUNDRY_MODEL="gpt-4o" # optional, defaults to gpt-4o ``` ## Samples diff --git a/python/samples/02-agents/background_responses.py b/python/samples/02-agents/background_responses.py index 002dc17a34..f3f3d7126a 100644 --- a/python/samples/02-agents/background_responses.py +++ b/python/samples/02-agents/background_responses.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -29,7 +29,7 @@ Prerequisites: agent = Agent( name="researcher", instructions="You are a helpful research assistant. Be concise.", - client=OpenAIResponsesClient(model="o3"), + client=OpenAIChatClient(model="o3"), ) diff --git a/python/samples/02-agents/chat_client/README.md b/python/samples/02-agents/chat_client/README.md index 6650e510a9..b7abb24e55 100644 --- a/python/samples/02-agents/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -9,28 +9,26 @@ This folder contains examples for direct chat client usage patterns. | [`built_in_chat_clients.py`](built_in_chat_clients.py) | Consolidated sample for built-in chat clients. Uses `get_client()` to create the selected client and pass it to `main()`. | | [`chat_response_cancellation.py`](chat_response_cancellation.py) | Demonstrates how to cancel chat responses during streaming, showing proper cancellation handling and cleanup. | | [`custom_chat_client.py`](custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `Agent` using the `as_agent()` method. | +| [`require_per_service_call_history_persistence.py`](require_per_service_call_history_persistence.py) | Compares two otherwise identical `FoundryChatClient` agents with `store=False`; the only difference is whether `require_per_service_call_history_persistence` is enabled, and only the run without it stores the synthesized tool result when middleware terminates the loop early. | ## Selecting a built-in client `built_in_chat_clients.py` starts with: ```python -asyncio.run(main("openai_chat")) +asyncio.run(main("openai_responses")) ``` Change the argument to pick a client: -- `openai_chat` - `openai_responses` -- `openai_assistants` +- `openai_chat_completion` - `anthropic` - `ollama` - `bedrock` -- `azure_openai_chat` - `azure_openai_responses` -- `azure_openai_responses_foundry` -- `azure_openai_assistants` -- `azure_ai_agent` +- `azure_openai_chat_completion` +- `foundry_chat` Example: @@ -38,37 +36,43 @@ Example: uv run samples/02-agents/chat_client/built_in_chat_clients.py ``` +The `require_per_service_call_history_persistence.py` sample uses `FoundryChatClient`, so set the usual Foundry settings first and sign in with the Azure CLI: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="" +az login +uv run samples/02-agents/chat_client/require_per_service_call_history_persistence.py +``` + ## Environment Variables Depending on the selected client, set the appropriate environment variables: -**For Azure clients:** +**For Azure OpenAI clients (`azure_openai_responses` and `azure_openai_chat_completion`):** - `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint -- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat deployment -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses deployment +- `AZURE_OPENAI_MODEL`: The Azure OpenAI deployment used by the sample +- `AZURE_OPENAI_API_VERSION` (optional): Azure OpenAI API version override +- `AZURE_OPENAI_API_KEY` (optional): Azure OpenAI API key if you are not using `AzureCliCredential` -**For Azure OpenAI Foundry responses client (`azure_openai_responses_foundry`):** -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses deployment - -**For Azure AI agent client (`azure_ai_agent`):** -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment (used by `azure_ai_agent`) +**For Foundry client (`foundry_chat`):** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: The Foundry deployment used by the sample **For OpenAI clients:** - `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL`: The OpenAI model for `openai_chat` and `openai_assistants` -- `OPENAI_RESPONSES_MODEL`: The OpenAI model for `openai_responses` +- `OPENAI_CHAT_COMPLETION_MODEL`: The OpenAI model for `openai_chat_completion` +- `OPENAI_CHAT_MODEL`: The OpenAI model for `openai_responses` **For Anthropic client (`anthropic`):** - `ANTHROPIC_API_KEY`: Your Anthropic API key -- `ANTHROPIC_CHAT_MODEL_ID`: The Anthropic model ID (for example, `claude-sonnet-4-5`) +- `ANTHROPIC_CHAT_MODEL`: The Anthropic model to use (for example, `claude-sonnet-4-5`) **For Ollama client (`ollama`):** - `OLLAMA_HOST`: Ollama server URL (defaults to `http://localhost:11434` if unset) -- `OLLAMA_MODEL_ID`: Ollama model name (for example, `mistral`, `qwen2.5:8b`) +- `OLLAMA_MODEL`: Ollama model name (for example, `mistral`, `qwen2.5:8b`) **For Bedrock client (`bedrock`):** -- `BEDROCK_CHAT_MODEL_ID`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) +- `BEDROCK_CHAT_MODEL`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) - `BEDROCK_REGION`: AWS region (defaults to `us-east-1` if unset) - AWS credentials via standard environment variables (for example, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) diff --git a/python/samples/02-agents/chat_client/built_in_chat_clients.py b/python/samples/02-agents/chat_client/built_in_chat_clients.py index b83a077bc5..4d79cc17b4 100644 --- a/python/samples/02-agents/chat_client/built_in_chat_clients.py +++ b/python/samples/02-agents/chat_client/built_in_chat_clients.py @@ -6,13 +6,9 @@ from random import randint from typing import Annotated, Any, Literal from agent_framework import Message, SupportsChatGetResponse, tool -from agent_framework.azure import ( - AzureOpenAIAssistantsClient, -) from agent_framework.foundry import FoundryChatClient -from agent_framework.openai import OpenAIAssistantsClient +from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -27,36 +23,28 @@ chat clients using a single `get_client` factory. Select one of these client names: - openai_chat -- openai_responses -- openai_assistants +- openai_chat_completion - anthropic - ollama - bedrock - azure_openai_chat -- azure_openai_responses -- azure_openai_responses_foundry -- azure_openai_assistants -- azure_ai_agent +- azure_openai_chat_completion +- foundry_chat """ ClientName = Literal[ "openai_chat", - "openai_responses", - "openai_assistants", + "openai_chat_completion", "anthropic", "ollama", "bedrock", "azure_openai_chat", - "azure_openai_responses", - "azure_openai_responses_foundry", - "azure_openai_assistants", - "azure_ai_agent", + "azure_openai_chat_completion", + "foundry_chat", ] # 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: Annotated[str, Field(description="The location to get the weather for.")], @@ -71,39 +59,27 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: from agent_framework.amazon import BedrockChatClient from agent_framework.anthropic import AnthropicClient from agent_framework.ollama import OllamaChatClient - from agent_framework.openai import OpenAIResponsesClient - # 1. Create OpenAI clients. if client_name == "openai_chat": - return FoundryChatClient() - if client_name == "openai_responses": - return OpenAIResponsesClient() - if client_name == "openai_assistants": - return OpenAIAssistantsClient() + return OpenAIChatClient() + if client_name == "openai_chat_completion": + return OpenAIChatCompletionClient() if client_name == "anthropic": return AnthropicClient() if client_name == "ollama": return OllamaChatClient() if client_name == "bedrock": return BedrockChatClient() - - # 2. Create Azure OpenAI clients. if client_name == "azure_openai_chat": - return FoundryChatClient(credential=AzureCliCredential()) - if client_name == "azure_openai_responses": - return FoundryChatClient(credential=AzureCliCredential(), api_version="preview") - if client_name == "azure_openai_responses_foundry": + return OpenAIChatClient(credential=AzureCliCredential()) + if client_name == "azure_openai_chat_completion": + return OpenAIChatCompletionClient(credential=AzureCliCredential()) + if client_name == "foundry_chat": return FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) - if client_name == "azure_openai_assistants": - return AzureOpenAIAssistantsClient(credential=AzureCliCredential()) - - # 3. Create Azure AI client. - if client_name == "azure_ai_agent": - return FoundryChatClient(credential=AsyncAzureCliCredential()) raise ValueError(f"Unsupported client name: {client_name}") @@ -112,14 +88,12 @@ async def main(client_name: ClientName = "openai_chat") -> None: """Run a basic prompt using a selected built-in client.""" client = get_client(client_name) - # 1. Configure prompt and streaming mode. - message = Message("user", text="What's the weather in Amsterdam and in Paris?") + message = Message("user", contents=["What's the weather in Amsterdam and in Paris?"]) stream = os.getenv("STREAM", "false").lower() == "true" print(f"Client: {client_name}") print(f"User: {message.text}") - # 2. Run with context-managed clients. - if isinstance(client, OpenAIAssistantsClient | AzureOpenAIAssistantsClient | FoundryChatClient): + if isinstance(client, FoundryChatClient): async with client: if stream: response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) @@ -134,7 +108,6 @@ async def main(client_name: ClientName = "openai_chat") -> None: ) return - # 3. Run with non-context-managed clients. if stream: response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) print("Assistant: ", end="") diff --git a/python/samples/02-agents/chat_client/chat_response_cancellation.py b/python/samples/02-agents/chat_client/chat_response_cancellation.py index 8fb71e7673..cd82be2602 100644 --- a/python/samples/02-agents/chat_client/chat_response_cancellation.py +++ b/python/samples/02-agents/chat_client/chat_response_cancellation.py @@ -4,6 +4,7 @@ import asyncio from agent_framework import Message from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -23,14 +24,14 @@ async def main() -> None: Creates a task for the chat request, waits briefly, then cancels it to show proper cleanup. Configuration: - - OpenAI model ID: Use "model_id" parameter or "OPENAI_MODEL" environment variable + - OpenAI model ID: Use "model" parameter or "OPENAI_MODEL" environment variable - OpenAI API key: Use "api_key" parameter or "OPENAI_API_KEY" environment variable """ - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) try: task = asyncio.create_task( - client.get_response(messages=[Message(role="user", text="Tell me a fantasy story.")]) + client.get_response(messages=[Message(role="user", contents=["Tell me a fantasy story."])]) ) await asyncio.sleep(1) task.cancel() diff --git a/python/samples/02-agents/chat_client/custom_chat_client.py b/python/samples/02-agents/chat_client/custom_chat_client.py index c677c09d8b..6027db4d37 100644 --- a/python/samples/02-agents/chat_client/custom_chat_client.py +++ b/python/samples/02-agents/chat_client/custom_chat_client.py @@ -98,11 +98,11 @@ class EchoingChatClient(BaseChatClient[OptionsT]): response_text = f"{response_text} {suffix}" stream_delay_seconds = float(options.get("stream_delay_seconds", 0.05)) - response_message = Message(role="assistant", text=response_text) + response_message = Message(role="assistant", contents=[response_text]) response = ChatResponse( messages=[response_message], - model_id="echo-model-v1", + model="echo-model-v1", response_id=f"echo-resp-{random.randint(1000, 9999)}", ) @@ -120,7 +120,7 @@ class EchoingChatClient(BaseChatClient[OptionsT]): contents=[Content.from_text(char)], role="assistant", response_id=f"echo-stream-resp-{random.randint(1000, 9999)}", - model_id="echo-model-v1", + model="echo-model-v1", ) await asyncio.sleep(stream_delay_seconds) @@ -150,7 +150,7 @@ async def main() -> None: # Use the chat client directly print("Using chat client directly:") direct_response = await echo_client.get_response( - [Message(role="user", text="Hello, custom chat client!")], + [Message(role="user", contents=["Hello, custom chat client!"])], options={ "uppercase": True, "suffix": "(CUSTOM OPTIONS)", diff --git a/python/samples/02-agents/chat_client/require_per_service_call_history_persistence.py b/python/samples/02-agents/chat_client/require_per_service_call_history_persistence.py new file mode 100644 index 0000000000..f3a9a9ddde --- /dev/null +++ b/python/samples/02-agents/chat_client/require_per_service_call_history_persistence.py @@ -0,0 +1,194 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Annotated + +from agent_framework import ( + Agent, + FunctionInvocationContext, + FunctionMiddleware, + InMemoryHistoryProvider, + Message, + MiddlewareTermination, +) +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +""" +Compare Foundry agents with and without per-service-call chat history persistence. + +This sample runs two otherwise identical Foundry agents with ``store=False`` so +history stays local for both runs. + +The sample adds a function middleware that raises ``MiddlewareTermination`` +immediately after the tool runs, so the request stops before a second model +call. + +That early termination is the important difference: + +- Without per-service-call chat history persistence, the synthesized tool result is + still written to local history. +- With ``require_per_service_call_history_persistence=True``, that synthesized tool result is + not written to local history. + +The per-service-call persistence case matches service-side storage behavior. When a terminated +request never sends the tool result back to the service, that result also never +becomes part of the service-managed history. +""" + +# Load environment variables from .env file +load_dotenv() + + +def lookup_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Return a deterministic weather result for the requested location.""" + return f"The weather in {location} is sunny." + + +class TerminateAfterToolMiddleware(FunctionMiddleware): + """Stop the tool loop after the first tool finishes.""" + + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + """Run the tool, then terminate the loop with that tool result.""" + await call_next() + raise MiddlewareTermination(result=context.result) + + +def _describe_message(message: Message) -> str: + """Render one stored message in a compact, readable format.""" + parts: list[str] = [] + for content in message.contents: + if content.type == "text" and content.text: + parts.append(content.text) + elif content.type == "function_call": + parts.append(f"function_call -> {content.name}({content.arguments})") + elif content.type == "function_result": + parts.append(f"function_result -> {content.result}") + else: + parts.append(content.type) + + return f"{message.role}: {' | '.join(parts)}" + + +def _includes_tool_result(messages: list[Message]) -> bool: + """Return whether any stored message contains a tool result.""" + return any(content.type == "function_result" for message in messages for content in message.contents) + + +async def main() -> None: + """Run both comparison scenarios.""" + print("=== require_per_service_call_history_persistence when middleware terminates the tool loop ===\n") + + # 1. Create one Foundry chat client that both agents will share. + client = FoundryChatClient(credential=AzureCliCredential()) + query = "What is the weather in Seattle, and should I bring sunglasses?" + + # 2. Create and run the agent without per-service-call persistence. + agent_without_persistence = Agent( + client=client, + instructions=( + "You are a weather assistant. Call lookup_weather exactly once before answering " + "any weather question, then summarize the tool result in one short paragraph." + ), + tools=[lookup_weather], + context_providers=[InMemoryHistoryProvider()], + middleware=[TerminateAfterToolMiddleware()], + default_options={"tool_choice": "required", "store": False}, + ) + session_without_persistence = agent_without_persistence.create_session() + await agent_without_persistence.run( + query, + session=session_without_persistence, + ) + stored_messages_without_persistence = session_without_persistence.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID][ + "messages" + ] + + print("=== Without per-service-call persistence ===") + print("Loop terminated immediately after the tool finished.") + print(f"Stored synthesized tool result: {_includes_tool_result(stored_messages_without_persistence)}") + print("Stored history:") + for index, message in enumerate(stored_messages_without_persistence, start=1): + print(f" {index}. {_describe_message(message)}") + print() + + # 3. Create and run the agent with per-service-call persistence enabled. + agent_with_persistence = Agent( + client=client, + instructions=( + "You are a weather assistant. Call lookup_weather exactly once before answering " + "any weather question, then summarize the tool result in one short paragraph." + ), + tools=[lookup_weather], + context_providers=[InMemoryHistoryProvider()], + middleware=[TerminateAfterToolMiddleware()], + require_per_service_call_history_persistence=True, + default_options={"tool_choice": "required", "store": False}, + ) + session_with_persistence = agent_with_persistence.create_session() + await agent_with_persistence.run( + query, + session=session_with_persistence, + ) + stored_messages_with_persistence = session_with_persistence.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID][ + "messages" + ] + + print("=== With per-service-call persistence ===") + print("Loop terminated immediately after the tool finished.") + print(f"Stored synthesized tool result: {_includes_tool_result(stored_messages_with_persistence)}") + print("Stored history:") + for index, message in enumerate(stored_messages_with_persistence, start=1): + print(f" {index}. {_describe_message(message)}") + print() + + # 4. Summarize the effect of the flag. + print( + "Both runs used FoundryChatClient with store=False and terminated right after the tool. " + "Without per-service-call persistence, local history still stored the synthesized tool result. " + "With per-service-call persistence, local history stopped at the assistant function-call message instead, " + "which matches service-side storage because the terminated tool result is never sent back to the service." + ) + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: +=== require_per_service_call_history_persistence when middleware terminates the tool loop === + +=== Without per-service-call persistence === +Loop terminated immediately after the tool finished. +Stored synthesized tool result: True +Stored history: + 1. user: What is the weather in Seattle, and should I bring sunglasses? + 2. assistant: function_call -> lookup_weather({"location":"Seattle"}) + 3. tool: function_result -> The weather in Seattle is sunny. + +=== With per-service-call persistence === +Loop terminated immediately after the tool finished. +Stored synthesized tool result: False +Stored history: + 1. user: What is the weather in Seattle, and should I bring sunglasses? + 2. assistant: function_call -> lookup_weather({"location":"Seattle"}) + +Both runs used FoundryChatClient with store=False and terminated right after +the tool. Without per-service-call persistence, local history still stored the +synthesized tool result. With per-service-call persistence, local history +stopped at the assistant function-call message instead, which matches +service-side storage because the terminated tool result is never sent back to +the service. +""" diff --git a/python/samples/02-agents/compaction/advanced.py b/python/samples/02-agents/compaction/advanced.py index 7cf1fc7f39..12482f131a 100644 --- a/python/samples/02-agents/compaction/advanced.py +++ b/python/samples/02-agents/compaction/advanced.py @@ -35,25 +35,27 @@ class BudgetSummaryClient: **kwargs: Any, ) -> ChatResponse: summary_text = f"Budget summary generated from {len(messages)} prompt messages." - return ChatResponse(messages=[Message(role="assistant", text=summary_text)]) + return ChatResponse(messages=[Message(role="assistant", contents=[summary_text])]) def _build_long_history() -> list[Message]: - history = [Message(role="system", text="You are a migration copilot.")] + history = [Message(role="system", contents=["You are a migration copilot."])] for i in range(1, 8): history.append( Message( role="user", - text=f"Iteration {i}: capture migration requirements and edge cases.", + contents=[f"Iteration {i}: capture migration requirements and edge cases."], ) ) history.append( Message( role="assistant", - text=( - f"Iteration {i}: detailed plan with dependencies, rollback guidance, and testing details. " - "This sentence is intentionally long to create token pressure." - ), + contents=[ + ( + f"Iteration {i}: detailed plan with dependencies, rollback guidance, and testing details. " + "This sentence is intentionally long to create token pressure." + ) + ], ) ) return history diff --git a/python/samples/02-agents/compaction/agent_client_overrides.py b/python/samples/02-agents/compaction/agent_client_overrides.py index bed7baa2a1..cca8898dd3 100644 --- a/python/samples/02-agents/compaction/agent_client_overrides.py +++ b/python/samples/02-agents/compaction/agent_client_overrides.py @@ -57,17 +57,17 @@ class InspectingChatClient(BaseChatClient[Any]): self.last_messages = list(messages) async def _get_response() -> ChatResponse: - return ChatResponse(messages=[Message(role="assistant", text="done")]) + return ChatResponse(messages=[Message(role="assistant", contents=["done"])]) return _get_response() def _build_messages() -> list[Message]: return [ - Message(role="user", text="Collect the deployment requirements."), - Message(role="assistant", text="I will gather the constraints first."), - Message(role="user", text="Summarize the rollout risks."), - Message(role="assistant", text="The main risks are drift, downtime, and rollback gaps."), + Message(role="user", contents=["Collect the deployment requirements."]), + Message(role="assistant", contents=["I will gather the constraints first."]), + Message(role="user", contents=["Summarize the rollout risks."]), + Message(role="assistant", contents=["The main risks are drift, downtime, and rollback gaps."]), ] diff --git a/python/samples/02-agents/compaction/basics.py b/python/samples/02-agents/compaction/basics.py index b75f9b5f47..a07524a037 100644 --- a/python/samples/02-agents/compaction/basics.py +++ b/python/samples/02-agents/compaction/basics.py @@ -51,15 +51,15 @@ class LocalSummaryClient: options: dict[str, Any] | None = None, **kwargs: Any, ) -> ChatResponse: - return ChatResponse(messages=[Message(role="assistant", text=f"Summary for {len(messages)} messages.")]) + return ChatResponse(messages=[Message(role="assistant", contents=[f"Summary for {len(messages)} messages."])]) async def main() -> None: # 1. Build one baseline history and print it once. messages = [ - Message(role="system", text="You are a helpful assistant."), - Message(role="user", text="Plan a data migration."), - Message(role="assistant", text="I will gather requirements."), + Message(role="system", contents=["You are a helpful assistant."]), + Message(role="user", contents=["Plan a data migration."]), + Message(role="assistant", contents=["I will gather requirements."]), Message( role="assistant", contents=[ @@ -79,9 +79,9 @@ async def main() -> None: ) ], ), - Message(role="assistant", text="I found three core tables."), - Message(role="user", text="Estimate effort and risks."), - Message(role="assistant", text="Primary risk is schema drift."), + Message(role="assistant", contents=["I found three core tables."]), + Message(role="user", contents=["Estimate effort and risks."]), + Message(role="assistant", contents=["Primary risk is schema drift."]), ] print("\n--- Before compaction ---") print(f"Message count: {len(messages)}") diff --git a/python/samples/02-agents/compaction/custom.py b/python/samples/02-agents/compaction/custom.py index da6acb066b..05302314fb 100644 --- a/python/samples/02-agents/compaction/custom.py +++ b/python/samples/02-agents/compaction/custom.py @@ -50,11 +50,11 @@ class KeepLastUserTurnStrategy: def _messages() -> list[Message]: return [ - Message(role="system", text="You are concise."), - Message(role="user", text="first request"), - Message(role="assistant", text="first response"), - Message(role="user", text="second request"), - Message(role="assistant", text="second response"), + Message(role="system", contents=["You are concise."]), + Message(role="user", contents=["first request"]), + Message(role="assistant", contents=["first response"]), + Message(role="user", contents=["second request"]), + Message(role="assistant", contents=["second response"]), ] diff --git a/python/samples/02-agents/compaction/tiktoken_tokenizer.py b/python/samples/02-agents/compaction/tiktoken_tokenizer.py index 7e2a5a7665..2d87036ce7 100644 --- a/python/samples/02-agents/compaction/tiktoken_tokenizer.py +++ b/python/samples/02-agents/compaction/tiktoken_tokenizer.py @@ -11,7 +11,7 @@ import asyncio from typing import Any -import tiktoken +import tiktoken # type: ignore from agent_framework import ( Message, TokenizerProtocol, @@ -33,9 +33,9 @@ Key components: class TiktokenTokenizer(TokenizerProtocol): """TokenizerProtocol implementation backed by tiktoken's o200k_base (gpt-4.1 and up default) encoding.""" - def __init__(self, *, encoding_name: str = "o200k_base", model_name: str | None = None) -> None: - if model_name is not None: - self._encoding = tiktoken.encoding_for_model(model_name) + def __init__(self, *, encoding_name: str = "o200k_base", model: str | None = None) -> None: + if model is not None: + self._encoding = tiktoken.encoding_for_model(model) else: self._encoding: Any = tiktoken.get_encoding(encoding_name) @@ -45,30 +45,34 @@ class TiktokenTokenizer(TokenizerProtocol): def _build_messages() -> list[Message]: return [ - Message(role="system", text="You are a migration assistant."), + Message(role="system", contents=["You are a migration assistant."]), Message( role="user", - text="List all migration risks and include detailed mitigations for each risk category.", + contents=["List all migration risks and include detailed mitigations for each risk category."], ), Message( role="assistant", - text=( - "Primary risks include schema drift, missing foreign key constraints, " - "and data quality regressions. Mitigations include staged validation, " - "shadow writes, and replay-based verification." - ), + contents=[ + ( + "Primary risks include schema drift, missing foreign key constraints, " + "and data quality regressions. Mitigations include staged validation, " + "shadow writes, and replay-based verification." + ) + ], ), Message( role="user", - text=("Now provide a detailed checklist with owners, rollback gates, and validation criteria."), + contents=[("Now provide a detailed checklist with owners, rollback gates, and validation criteria.")], ), Message( role="assistant", - text=( - "Checklist: baseline snapshots, migration dry-run, production " - "canary, progressive deployment, automated integrity checks, and " - "post-migration reconciliation." - ), + contents=[ + ( + "Checklist: baseline snapshots, migration dry-run, production " + "canary, progressive deployment, automated integrity checks, and " + "post-migration reconciliation." + ) + ], ), ] diff --git a/python/samples/02-agents/context_providers/README.md b/python/samples/02-agents/context_providers/README.md new file mode 100644 index 0000000000..04f3a1395f --- /dev/null +++ b/python/samples/02-agents/context_providers/README.md @@ -0,0 +1,28 @@ +# Context Provider Samples + +These samples demonstrate how to use context providers to enrich agent conversations with external knowledge — from custom logic to Azure AI Search (RAG) and memory services. + +## Samples + +| File / Folder | Description | +|---------------|-------------| +| [`simple_context_provider.py`](simple_context_provider.py) | Implement a custom context provider by extending `ContextProvider` to extract and inject structured user information across turns. | +| [`azure_ai_foundry_memory.py`](azure_ai_foundry_memory.py) | Use `FoundryMemoryProvider` to add semantic memory — automatically retrieves, searches, and stores memories via Azure AI Foundry. | +| [`azure_ai_search/`](azure_ai_search/) | Retrieval Augmented Generation (RAG) with Azure AI Search in semantic and agentic modes. See its own [README](azure_ai_search/README.md). | +| [`mem0/`](mem0/) | Memory-powered context using the Mem0 integration (open-source and managed). See its own [README](mem0/README.md). | +| [`redis/`](redis/) | Redis-backed context providers for conversation memory and sessions. See its own [README](redis/README.md). | + +## Prerequisites + +**For `simple_context_provider.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: Model deployment name +- Azure CLI authentication (`az login`) + +**For `azure_ai_foundry_memory.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: Chat/responses model deployment name +- `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`: Embedding model deployment name (e.g., `text-embedding-ada-002`) +- Azure CLI authentication (`az login`) + +See each subfolder's README for provider-specific prerequisites. diff --git a/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py b/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py index 9c27e5857f..fbbeb20b14 100644 --- a/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py +++ b/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py @@ -33,7 +33,7 @@ rather than chat history. The memory store is deleted at the end of the run. Prerequisites: 1. Set FOUNDRY_PROJECT_ENDPOINT environment variable 2. Set FOUNDRY_MODEL for the chat/responses model -3. Set AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME for the embedding model +3. Set AZURE_OPENAI_EMBEDDING_MODEL for the embedding model 4. Deploy both a chat model (e.g. gpt-4) and an embedding model (e.g. text-embedding-3-small) """ load_dotenv() @@ -55,7 +55,7 @@ async def main() -> None: ) memory_store_definition = MemoryStoreDefaultDefinition( chat_model=os.environ["FOUNDRY_MODEL"], - embedding_model=os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + embedding_model=os.environ["AZURE_OPENAI_EMBEDDING_MODEL"], options=options, ) print(f"Creating memory store '{memory_store_name}'...") diff --git a/python/samples/02-agents/context_providers/azure_ai_search/README.md b/python/samples/02-agents/context_providers/azure_ai_search/README.md index 6c7f7de711..9e5f6c03f2 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/README.md +++ b/python/samples/02-agents/context_providers/azure_ai_search/README.md @@ -49,14 +49,14 @@ Run `az login` if using Entra ID authentication. **Common (both modes):** - `AZURE_SEARCH_ENDPOINT`: Your Azure AI Search endpoint (e.g., `https://myservice.search.windows.net`) - `AZURE_SEARCH_INDEX_NAME`: Name of your search index -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: Model deployment name (e.g., `gpt-4o`, defaults to `gpt-4o`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: Model deployment name (e.g., `gpt-4o`, defaults to `gpt-4o`) - `AZURE_SEARCH_API_KEY`: _(Optional)_ Your search API key - if not provided, uses DefaultAzureCredential **Agentic mode only:** - `AZURE_SEARCH_KNOWLEDGE_BASE_NAME`: Name of your Knowledge Base in Azure AI Search - `AZURE_OPENAI_RESOURCE_URL`: Your Azure OpenAI resource URL (e.g., `https://myresource.openai.azure.com`) - - **Important**: This is different from `AZURE_AI_PROJECT_ENDPOINT` - Knowledge Base needs the OpenAI endpoint for model calls + - **Important**: This is different from `FOUNDRY_PROJECT_ENDPOINT` - Knowledge Base needs the OpenAI endpoint for model calls ### Example .env file @@ -64,8 +64,8 @@ Run `az login` if using Entra ID authentication. ```env AZURE_SEARCH_ENDPOINT=https://myservice.search.windows.net AZURE_SEARCH_INDEX_NAME=my-index -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4o # Optional - omit to use Entra ID AZURE_SEARCH_API_KEY=your-search-key ``` @@ -127,7 +127,8 @@ AZURE_OPENAI_RESOURCE_URL=https://myresource.openai.azure.com ```python from agent_framework import Agent -from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider +from agent_framework.azure import AzureAISearchContextProvider +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import DefaultAzureCredential # Create search provider with semantic mode (default) @@ -140,10 +141,13 @@ search_provider = AzureAISearchContextProvider( ) # Create agent with search context -async with AzureAIAgentClient(credential=DefaultAzureCredential()) as client: +async with FoundryChatClient( + project_endpoint=project_endpoint, + model=model_deployment, + credential=DefaultAzureCredential(), +) as client: async with Agent( client=client, - model=model_deployment, context_providers=[search_provider], ) as agent: response = await agent.run("What information is in the knowledge base?") diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py index 36612d3b8e..2d57a2906b 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py @@ -34,7 +34,7 @@ Environment variables: - AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint - AZURE_SEARCH_API_KEY: (Optional) API key - if not provided, uses AzureCliCredential - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") + - FOUNDRY_MODEL: Your model deployment name (e.g., "gpt-4o") For using an existing Knowledge Base (recommended): - AZURE_SEARCH_KNOWLEDGE_BASE_NAME: Your Knowledge Base name @@ -59,7 +59,7 @@ async def main() -> None: search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] search_key = os.environ.get("AZURE_SEARCH_API_KEY") project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] - model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") + model_deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o") # Agentic mode requires exactly ONE of: knowledge_base_name OR index_name # Option 1: Use existing Knowledge Base (recommended) @@ -100,7 +100,7 @@ async def main() -> None: credential=AzureCliCredential() if not search_key else None, mode="agentic", azure_openai_resource_url=azure_openai_resource_url, - model_model=model_deployment, + model_deployment_name=model_deployment, # Optional: Configure retrieval behavior knowledge_base_output_mode="extractive_data", # or "answer_synthesis" retrieval_reasoning_effort="minimal", # or "medium", "low" @@ -110,13 +110,12 @@ async def main() -> None: # Create agent with search context provider async with ( search_provider, - FoundryChatClient( - project_endpoint=project_endpoint, - model_model=model_deployment, - credential=AzureCliCredential(), - ) as client, Agent( - client=client, + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model_deployment, + credential=AzureCliCredential(), + ), name="SearchAgent", instructions=( "You are a helpful assistant with advanced reasoning capabilities. " diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index 98b7d66f88..5f2a57f511 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -31,8 +31,8 @@ Prerequisites: - AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses AzureCliCredential for Entra ID - AZURE_SEARCH_INDEX_NAME: Your search index name - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") - - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: (Optional) Your Azure OpenAI embedding deployment for hybrid search + - FOUNDRY_MODEL: Your model deployment name (e.g., "gpt-4o") + - AZURE_OPENAI_EMBEDDING_MODEL: (Optional) Your Azure OpenAI embedding deployment for hybrid search - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using Azure OpenAI embeddings """ @@ -54,9 +54,9 @@ async def main() -> None: search_key = os.environ.get("AZURE_SEARCH_API_KEY") index_name = os.environ["AZURE_SEARCH_INDEX_NAME"] project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] - model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") + model_deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o") openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") - embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") + embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_MODEL") embedding_client = None if openai_endpoint and embedding_deployment: @@ -85,13 +85,12 @@ async def main() -> None: # Create agent with search context provider async with ( search_provider, - FoundryChatClient( - project_endpoint=project_endpoint, - model_model=model_deployment, - credential=credential, - ) as client, Agent( - client=client, + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model_deployment, + credential=credential, + ), name="SearchAgent", instructions=( "You are a helpful assistant. Use the provided context from the " diff --git a/python/samples/02-agents/context_providers/mem0/README.md b/python/samples/02-agents/context_providers/mem0/README.md index 4c12bb67d8..c4a3cdbc61 100644 --- a/python/samples/02-agents/context_providers/mem0/README.md +++ b/python/samples/02-agents/context_providers/mem0/README.md @@ -9,7 +9,7 @@ This folder contains examples demonstrating how to use the Mem0 context provider | File | Description | |------|-------------| | [`mem0_basic.py`](mem0_basic.py) | Basic example of using Mem0 context provider to store and retrieve user preferences across different conversation threads. | -| [`mem0_sessions.py`](mem0_sessions.py) | Advanced example demonstrating different thread scoping strategies with Mem0. Covers global thread scope (memories shared across all operations), per-operation thread scope (memories isolated per thread), and multiple agents with different memory configurations for personal vs. work contexts. | +| [`mem0_sessions.py`](mem0_sessions.py) | Example demonstrating different memory scoping strategies with Mem0. Covers user-scoped memory (memories shared across all sessions for the same user), agent-scoped memory (memories isolated per agent), and multiple agents with different memory configurations for personal vs. work contexts. | | [`mem0_oss.py`](mem0_oss.py) | Example of using the Mem0 Open Source self-hosted version as the context provider. Demonstrates setup and configuration for local deployment. | ## Prerequisites @@ -33,23 +33,15 @@ Set the following environment variables: - `OPENAI_API_KEY`: Your OpenAI API key (used by Mem0 OSS for embedding generation and automatic memory extraction) **For Azure AI:** -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI project endpoint +- `FOUNDRY_MODEL`: The name of your model deployment ## Key Concepts ### Memory Scoping -The Mem0 context provider supports different scoping strategies: +The Mem0 context provider supports scoping via identifiers: -- **Global Scope** (`scope_to_per_operation_thread_id=False`): Memories are shared across all conversation threads -- **Thread Scope** (`scope_to_per_operation_thread_id=True`): Memories are isolated per conversation thread - -### Memory Association - -Mem0 records can be associated with different identifiers: - -- `user_id`: Associate memories with a specific user -- `agent_id`: Associate memories with a specific agent -- `thread_id`: Associate memories with a specific conversation thread -- `application_id`: Associate memories with an application context +- **User scope** (`user_id`): Associate memories with a specific user, shared across all sessions +- **Agent scope** (`agent_id`): Isolate memories per agent persona +- **Application scope** (`application_id`): Associate memories with an application context diff --git a/python/samples/02-agents/context_providers/mem0/mem0_basic.py b/python/samples/02-agents/context_providers/mem0/mem0_basic.py index 46b185573b..712d3b0491 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_basic.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_basic.py @@ -31,7 +31,7 @@ def retrieve_company_report(company_code: str, detailed: bool) -> str: async def main() -> None: """Example of memory usage with Mem0 context provider.""" print("=== Mem0 Context Provider Example ===") - # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. + # Each record in Mem0 should be associated with agent_id or user_id or application_id. # In this example, we associate Mem0 records with user_id. user_id = str(uuid.uuid4()) # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred @@ -57,12 +57,16 @@ async def main() -> None: # Now tell the agent the company code and the report format that you want to use # and it should be able to invoke the tool and return the report. query = "I always work with CNTS and I always want a detailed report format. Please remember and retrieve it." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + # Mem0 processes and indexes memories asynchronously. # Wait for memories to be indexed before querying in a new thread. # In production, consider implementing retry logic or using Mem0's # eventual consistency handling instead of a fixed delay. print("Waiting for memories to be processed...") - await asyncio.sleep(12) # Empirically determined delay for Mem0 indexing + await asyncio.sleep(15) # Empirically determined delay for Mem0 indexing print("\nRequest within a new session:") # Create a new session for the agent. # The new session has no context of the previous conversation. @@ -70,7 +74,10 @@ async def main() -> None: # Since we have the mem0 component in the session, the agent should be able to # retrieve the company report without asking for clarification, as it will # be able to remember the user preferences from Mem0 component. + query = "Please retrieve my company report" + print(f"User: {query}") result = await agent.run(query, session=session) + print(f"Agent: {result}") if __name__ == "__main__": diff --git a/python/samples/02-agents/context_providers/mem0/mem0_oss.py b/python/samples/02-agents/context_providers/mem0/mem0_oss.py index 9126ab4bdc..a0c4bbb372 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_oss.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_oss.py @@ -32,7 +32,7 @@ def retrieve_company_report(company_code: str, detailed: bool) -> str: async def main() -> None: """Example of memory usage with local Mem0 OSS context provider.""" print("=== Mem0 Context Provider Example ===") - # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. + # Each record in Mem0 should be associated with agent_id or user_id or application_id. # In this example, we associate Mem0 records with user_id. user_id = str(uuid.uuid4()) # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred diff --git a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py index 80976fa8f3..2ba6113d15 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import uuid from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient @@ -27,103 +26,84 @@ def get_user_preferences(user_id: str) -> str: return preferences.get(user_id, "No specific preferences found") -async def example_global_thread_scope() -> None: - """Example 1: Global thread_id scope (memories shared across all operations).""" - print("1. Global Thread Scope Example:") +async def example_user_scoped_memory() -> None: + """Example 1: User-scoped memory (memories shared across all sessions for the same user).""" + print("1. User-Scoped Memory Example:") print("-" * 40) - global_thread_id = str(uuid.uuid4()) user_id = "user123" async with ( AzureCliCredential() as credential, Agent( client=FoundryChatClient(credential=credential), - name="GlobalMemoryAssistant", + name="UserMemoryAssistant", instructions="You are an assistant that remembers user preferences across conversations.", tools=get_user_preferences, context_providers=[ Mem0ContextProvider( source_id="mem0", user_id=user_id, - thread_id=global_thread_id, - scope_to_per_operation_thread_id=False, # Share memories across all sessions ) ], - ) as global_agent, + ) as user_agent, ): - # Store some preferences in the global scope + # Store some preferences query = "Remember that I prefer technical responses with code examples when discussing programming." print(f"User: {query}") - result = await global_agent.run(query) + result = await user_agent.run(query) print(f"Agent: {result}\n") - # Create a new session - but memories should still be accessible due to global scope - new_session = global_agent.create_session() + # Create a new session - memories should still be accessible via user_id scoping + new_session = user_agent.create_session() query = "What do you know about my preferences?" print(f"User (new session): {query}") - result = await global_agent.run(query, session=new_session) + result = await user_agent.run(query, session=new_session) print(f"Agent: {result}\n") -async def example_per_operation_thread_scope() -> None: - """Example 2: Per-operation thread scope (memories isolated per session). +async def example_agent_scoped_memory() -> None: + """Example 2: Agent-scoped memory (memories isolated per agent_id). - Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session - throughout its lifetime. Use the same session object for all operations with that provider. + Note: Use different agent_id values to isolate memories between different + agent personas, even when the user_id is the same. """ - print("2. Per-Operation Thread Scope Example:") + print("2. Agent-Scoped Memory Example:") print("-" * 40) - user_id = "user123" - async with ( AzureCliCredential() as credential, Agent( client=FoundryChatClient(credential=credential), name="ScopedMemoryAssistant", - instructions="You are an assistant with thread-scoped memory.", + instructions="You are an assistant with agent-scoped memory.", tools=get_user_preferences, context_providers=[ Mem0ContextProvider( source_id="mem0", - user_id=user_id, - scope_to_per_operation_thread_id=True, # Isolate memories per session + agent_id="scoped_assistant", ) ], ) as scoped_agent, ): - # Create a specific session for this scoped provider - dedicated_session = scoped_agent.create_session() - - # Store some information in the dedicated session - query = "Remember that for this conversation, I'm working on a Python project about data analysis." - print(f"User (dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) + query = ( + "Remember that I'm working on a Python project about data analysis " + "and I prefer using pandas and matplotlib." + ) + print(f"User: {query}") + result = await scoped_agent.run(query) print(f"Agent: {result}\n") - # Test memory retrieval in the same dedicated session - query = "What project am I working on?" - print(f"User (same dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) - print(f"Agent: {result}\n") - - # Store more information in the same session - query = "Also remember that I prefer using pandas and matplotlib for this project." - print(f"User (same dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) - print(f"Agent: {result}\n") - - # Test comprehensive memory retrieval + new_session = scoped_agent.create_session() query = "What do you know about my current project and preferences?" - print(f"User (same dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) + print(f"User (new session): {query}") + result = await scoped_agent.run(query, session=new_session) print(f"Agent: {result}\n") async def example_multiple_agents() -> None: - """Example 3: Multiple agents with different thread configurations.""" - print("3. Multiple Agents with Different Thread Configurations:") + """Example 3: Multiple agents with different memory configurations.""" + print("3. Multiple Agents with Different Memory Configurations:") print("-" * 40) agent_id_1 = "agent_personal" @@ -178,11 +158,11 @@ async def example_multiple_agents() -> None: async def main() -> None: - """Run all Mem0 thread management examples.""" - print("=== Mem0 Thread Management Example ===\n") + """Run all Mem0 memory management examples.""" + print("=== Mem0 Memory Management Example ===\n") - await example_global_thread_scope() - await example_per_operation_thread_scope() + await example_user_scoped_memory() + await example_agent_scoped_memory() await example_multiple_agents() diff --git a/python/samples/02-agents/context_providers/redis/README.md b/python/samples/02-agents/context_providers/redis/README.md index 060061c908..104ea3a5b8 100644 --- a/python/samples/02-agents/context_providers/redis/README.md +++ b/python/samples/02-agents/context_providers/redis/README.md @@ -11,7 +11,7 @@ This folder contains an example demonstrating how to use the Redis context provi | [`azure_redis_conversation.py`](azure_redis_conversation.py) | Demonstrates conversation persistence with RedisHistoryProvider and Azure Redis with Azure AD (Entra ID) authentication using credential provider. | | [`redis_basics.py`](redis_basics.py) | Shows standalone provider usage and agent integration. Demonstrates writing messages to Redis, retrieving context via full‑text or hybrid vector search, and persisting preferences across threads. Also includes a simple tool example whose outputs are remembered. | | [`redis_conversation.py`](redis_conversation.py) | Simple example showing conversation persistence with RedisContextProvider using traditional connection string authentication. | -| [`redis_sessions.py`](redis_sessions.py) | Demonstrates thread scoping. Includes: (1) global thread scope with a fixed `thread_id` shared across operations; (2) per‑operation thread scope where `scope_to_per_operation_thread_id=True` binds memory to a single thread for the provider's lifetime; and (3) multiple agents with isolated memory via different `agent_id` values. | +| [`redis_sessions.py`](redis_sessions.py) | Demonstrates memory scoping strategies. Includes: (1) global memory scope with `application_id`, `agent_id`, and `user_id` shared across operations; (2) hybrid vector search using a custom OpenAI vectorizer for richer context retrieval; and (3) multiple agents with isolated memory via different `agent_id` values. | ## Prerequisites @@ -51,8 +51,8 @@ See quickstart: `https://learn.microsoft.com/azure/redis/quickstart-create-manag ### Environment variables -- `AZURE_AI_PROJECT_ENDPOINT` (required): Azure AI Foundry project endpoint for `AzureOpenAIResponsesClient` -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` (required): Azure OpenAI Responses deployment name +- `FOUNDRY_PROJECT_ENDPOINT` (required): Azure AI Foundry project endpoint for `FoundryChatClient` +- `FOUNDRY_MODEL` (required): Foundry model deployment name - `OPENAI_API_KEY` (optional): Required only if you set `vectorizer_choice="openai"` to enable hybrid search. ### Provider configuration highlights @@ -61,8 +61,7 @@ The provider supports both full‑text only and hybrid vector search: - Set `vectorizer_choice` to `"openai"` or `"hf"` to enable embeddings and hybrid search. - When using a vectorizer, also set `vector_field_name` (e.g., `"vector"`). -- Partition fields for scoping memory: `application_id`, `agent_id`, `user_id`, `thread_id`. -- Thread scoping: `scope_to_per_operation_thread_id=True` isolates memory per operation thread. +- Partition fields for scoping memory: `application_id`, `agent_id`, `user_id`. - Index management: `index_name`, `overwrite_redis_index`, `drop_redis_index`. ## What the example does @@ -73,7 +72,7 @@ The provider supports both full‑text only and hybrid vector search: 2. Agent integration: teaches the agent a preference and verifies it is remembered across turns. 3. Agent + tool: calls a sample tool (flight search) and then asks the agent to recall details remembered from the tool output. -It uses `AzureOpenAIResponsesClient` (Foundry project endpoint setup) for chat and, in some steps, optional OpenAI embeddings for hybrid search. +It uses `FoundryChatClient` for chat and, in some steps, optional OpenAI embeddings for hybrid search. ## How to run @@ -82,8 +81,8 @@ It uses `AzureOpenAIResponsesClient` (Foundry project endpoint setup) for chat a 2) Set Azure Foundry/OpenAI responses environment variables: ```bash -export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="" +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="" ``` 3) (Optional) Set your OpenAI key if using embeddings: @@ -104,8 +103,8 @@ You should see the agent responses and, when using embeddings, context retrieved ### Memory scoping -- Global scope: set `application_id`, `agent_id`, `user_id`, or `thread_id` on the provider to filter memory. -- Per‑operation thread scope: set `scope_to_per_operation_thread_id=True` to isolate memory to the current thread created by the framework. +- Global scope: set `application_id`, `agent_id`, or `user_id` on the provider to filter memory. +- Agent isolation: use different `agent_id` values to keep memories separated for different agent personas. ### Hybrid vector search (optional) @@ -118,7 +117,7 @@ You should see the agent responses and, when using embeddings, context retrieved ## Troubleshooting -- Ensure at least one of `application_id`, `agent_id`, `user_id`, or `thread_id` is set; the provider requires a scope. -- Verify `AZURE_AI_PROJECT_ENDPOINT` and `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` are set for the chat client. +- Ensure at least one of `application_id`, `agent_id`, or `user_id` is set; the provider requires a scope. +- Verify `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` are set for the chat client. - If using embeddings, verify `OPENAI_API_KEY` is set and reachable. - Make sure Redis exposes RediSearch (Redis Stack image or managed service with search enabled). diff --git a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py index ee0757d907..a9e72d83d7 100644 --- a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py @@ -30,9 +30,10 @@ from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisHistoryProvider from azure.identity import AzureCliCredential from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential +from dotenv import load_dotenv from redis.credentials import CredentialProvider -# Copyright (c) Microsoft. All rights reserved. +load_dotenv() class AzureCredentialProvider(CredentialProvider): diff --git a/python/samples/02-agents/context_providers/redis/redis_basics.py b/python/samples/02-agents/context_providers/redis/redis_basics.py index efe433e5d4..bf9e163a49 100644 --- a/python/samples/02-agents/context_providers/redis/redis_basics.py +++ b/python/samples/02-agents/context_providers/redis/redis_basics.py @@ -100,7 +100,7 @@ def search_flights(origin_airport_code: str, destination_airport_code: str, deta def create_chat_client() -> FoundryChatClient: - """Create an Azure OpenAI Responses client using a Foundry project endpoint.""" + """Create a FoundryChatClient using a Foundry project endpoint.""" return FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL"], diff --git a/python/samples/02-agents/context_providers/redis/redis_sessions.py b/python/samples/02-agents/context_providers/redis/redis_sessions.py index 92635a5326..de1ef8e095 100644 --- a/python/samples/02-agents/context_providers/redis/redis_sessions.py +++ b/python/samples/02-agents/context_providers/redis/redis_sessions.py @@ -1,17 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. -"""Redis Context Provider: Thread scoping examples +"""Redis Context Provider: Memory scoping examples This sample demonstrates how conversational memory can be scoped when using the Redis context provider. It covers three scenarios: -1) Global thread scope - - Provide a fixed thread_id to share memories across operations/threads. +1) Global memory scope + - Use application_id, agent_id, and user_id to share memories across + all operations/sessions. -2) Per-operation thread scope - - Enable scope_to_per_operation_thread_id to bind the provider to a single - thread for the lifetime of that provider instance. Use the same thread - object for reads/writes with that provider. +2) Hybrid vector search + - Use a custom OpenAI vectorizer with the provider for hybrid vector search. + Demonstrates combining full-text and semantic search for richer context + retrieval. 3) Multiple agents with isolated memory - Use different agent_id values to keep memories separated for different @@ -23,7 +24,7 @@ Requirements: - Optionally an OpenAI API key for the chat client in this demo Run: - python redis_threads.py + python redis_sessions.py """ import asyncio @@ -33,10 +34,12 @@ from agent_framework import Agent from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisContextProvider from azure.identity import AzureCliCredential +from dotenv import load_dotenv from redisvl.extensions.cache.embeddings import EmbeddingsCache from redisvl.utils.vectorize import OpenAITextVectorizer -# Copyright (c) Microsoft. All rights reserved. +# Load environment variables from .env file +load_dotenv() # Default Redis URL for local Redis Stack. @@ -47,7 +50,7 @@ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") # Please set OPENAI_API_KEY to use the OpenAI vectorizer. # For chat responses, also set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL. def create_chat_client() -> FoundryChatClient: - """Create an Azure OpenAI Responses client using a Foundry project endpoint.""" + """Create a FoundryChatClient using a Foundry project endpoint.""" return FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL"], @@ -55,9 +58,9 @@ def create_chat_client() -> FoundryChatClient: ) -async def example_global_thread_scope() -> None: - """Example 1: Global thread_id scope (memories shared across all operations).""" - print("1. Global Thread Scope Example:") +async def example_global_memory_scope() -> None: + """Example 1: Global memory scope (memories shared across all operations).""" + print("1. Global Memory Scope Example:") print("-" * 40) client = create_chat_client() @@ -99,13 +102,13 @@ async def example_global_thread_scope() -> None: await provider.redis_index.delete() -async def example_per_operation_thread_scope() -> None: - """Example 2: Per-operation thread scope (memories isolated per session). +async def example_hybrid_vector_search() -> None: + """Example 2: Hybrid vector search with custom vectorizer. - Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session - throughout its lifetime. Use the same session object for all operations with that provider. + Demonstrates using a custom OpenAI vectorizer for hybrid vector search, + combining full-text and semantic search for richer context retrieval. """ - print("2. Per-Operation Thread Scope Example:") + print("2. Hybrid Vector Search Example:") print("-" * 40) client = create_chat_client() @@ -131,36 +134,33 @@ async def example_per_operation_thread_scope() -> None: agent = Agent( client=client, - name="ScopedMemoryAssistant", - instructions="You are an assistant with thread-scoped memory.", + name="HybridSearchAssistant", + instructions="You are an assistant with hybrid vector search for richer context retrieval.", context_providers=[provider], ) - # Create a specific session for this scoped provider - dedicated_session = agent.create_session() - - # Store some information in the dedicated session + # Store some information query = "Remember that for this conversation, I'm working on a Python project about data analysis." - print(f"User (dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") - # Test memory retrieval in the same dedicated session + # Test memory retrieval via hybrid search query = "What project am I working on?" - print(f"User (same dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") - # Store more information in the same session + # Store more information query = "Also remember that I prefer using pandas and matplotlib for this project." - print(f"User (same dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") # Test comprehensive memory retrieval query = "What do you know about my current project and preferences?" - print(f"User (same dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") # Clean up the Redis index @@ -168,8 +168,8 @@ async def example_per_operation_thread_scope() -> None: async def example_multiple_agents() -> None: - """Example 3: Multiple agents with different thread configurations (isolated via agent_id) but within 1 index.""" - print("3. Multiple Agents with Different Thread Configurations:") + """Example 3: Multiple agents with different memory configurations (isolated via agent_id) but within 1 index.""" + print("3. Multiple Agents with Different Memory Configurations:") print("-" * 40) client = create_chat_client() @@ -247,9 +247,9 @@ async def example_multiple_agents() -> None: async def main() -> None: - print("=== Redis Thread Scoping Examples ===\n") - await example_global_thread_scope() - await example_per_operation_thread_scope() + print("=== Redis Memory Scoping Examples ===\n") + await example_global_memory_scope() + await example_hybrid_vector_search() await example_multiple_agents() diff --git a/python/samples/02-agents/context_providers/simple_context_provider.py b/python/samples/02-agents/context_providers/simple_context_provider.py index 265a93a73e..dd8da8cbe6 100644 --- a/python/samples/02-agents/context_providers/simple_context_provider.py +++ b/python/samples/02-agents/context_providers/simple_context_provider.py @@ -5,7 +5,7 @@ import os from contextlib import suppress from typing import Any -from agent_framework import Agent, AgentSession, BaseContextProvider, SessionContext, SupportsChatGetResponse +from agent_framework import Agent, AgentSession, ContextProvider, SessionContext, SupportsChatGetResponse from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -20,7 +20,7 @@ class UserInfo(BaseModel): age: int | None = None -class UserInfoMemory(BaseContextProvider): +class UserInfoMemory(ContextProvider): DEFAULT_SOURCE_ID = "user_info_memory" def __init__(self, source_id: str = DEFAULT_SOURCE_ID, *, client: SupportsChatGetResponse, **kwargs: Any): @@ -50,9 +50,11 @@ class UserInfoMemory(BaseContextProvider): # Use the chat client to extract structured information result = await self._chat_client.get_response( messages=request_messages, # type: ignore - instructions="Extract the user's name and age from the message if present. " - "If not present return nulls.", - options={"response_format": UserInfo}, + options={ + "instructions": "Extract the user's name and age from the message if present. " + "If not present return nulls.", + "response_format": UserInfo, + }, ) # Update user info with extracted data diff --git a/python/samples/02-agents/conversations/README.md b/python/samples/02-agents/conversations/README.md new file mode 100644 index 0000000000..becacd9134 --- /dev/null +++ b/python/samples/02-agents/conversations/README.md @@ -0,0 +1,37 @@ +# Conversation & Session Management Samples + +These samples demonstrate different approaches to managing conversation history and session state in Agent Framework. + +## Samples + +| File | Description | +|------|-------------| +| [`suspend_resume_session.py`](suspend_resume_session.py) | Suspend and resume conversation sessions, comparing service-managed sessions (Azure AI Foundry) with in-memory sessions (OpenAI). | +| [`custom_history_provider.py`](custom_history_provider.py) | Implement a custom history provider by extending `HistoryProvider`, enabling conversation persistence in your preferred storage backend. | +| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Use Azure Cosmos DB as a history provider for durable conversation storage with `CosmosHistoryProvider`. | +| [`redis_history_provider.py`](redis_history_provider.py) | Use Redis as a history provider for persistent conversation history storage across sessions. | + +## Prerequisites + +**For `suspend_resume_session.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint (service-managed session) +- `FOUNDRY_MODEL`: The Foundry model deployment name +- `OPENAI_API_KEY`: Your OpenAI API key (in-memory session) +- Azure CLI authentication (`az login`) + +**For `custom_history_provider.py`:** +- `OPENAI_API_KEY`: Your OpenAI API key + +**For `cosmos_history_provider.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: The Foundry model deployment name +- `AZURE_COSMOS_ENDPOINT`: Your Azure Cosmos DB account endpoint +- `AZURE_COSMOS_DATABASE_NAME`: The database that stores conversation history +- `AZURE_COSMOS_CONTAINER_NAME`: The container that stores conversation history +- Either `AZURE_COSMOS_KEY` or Azure CLI authentication (`az login`) + +**For `redis_history_provider.py`:** +- `OPENAI_API_KEY`: Your OpenAI API key +- A running Redis server — default URL is `redis://localhost:6379` + - Override via the `REDIS_URL` environment variable for remote or authenticated instances + - Quickstart with Docker: `docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest` diff --git a/python/samples/02-agents/conversations/cosmos_history_provider.py b/python/samples/02-agents/conversations/cosmos_history_provider.py new file mode 100644 index 0000000000..5dc5c54b65 --- /dev/null +++ b/python/samples/02-agents/conversations/cosmos_history_provider.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.azure import CosmosHistoryProvider +from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file. +load_dotenv() + +""" +This sample demonstrates CosmosHistoryProvider as an agent history provider. + +Key components: +- FoundryChatClient configured with an Azure AI project endpoint +- CosmosHistoryProvider configured for Cosmos DB-backed message history +- Provider-configured container name with session_id as partition key + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL + AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_DATABASE_NAME + AZURE_COSMOS_CONTAINER_NAME +Optional: + AZURE_COSMOS_KEY +""" + + +async def main() -> None: + """Run the Cosmos history provider sample with an Agent.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if ( + not project_endpoint + or not model + or not cosmos_endpoint + or not cosmos_database_name + or not cosmos_container_name + ): + print( + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + # 1. Create an Azure credential and a CosmosHistoryProvider for agent context + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + # 2. Create an agent that uses Cosmos for persisted conversation history. + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="CosmosHistoryAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + # 3. Create a session (session_id is used as the partition key). + session = agent.create_session() + + # 4. Run a multi-turn conversation; history is persisted by CosmosHistoryProvider. + response1 = await agent.run("My name is Ada and I enjoy distributed systems.", session=session) + print(f"Assistant: {response1.text}") + + response2 = await agent.run("What do you remember about me?", session=session) + print(f"Assistant: {response2.text}") + print(f"Container: {history_provider.container_name}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +Assistant: Nice to meet you, Ada! Distributed systems are a fascinating area. +Assistant: You told me your name is Ada and that you enjoy distributed systems. +Container: +""" diff --git a/python/samples/02-agents/conversations/custom_history_provider.py b/python/samples/02-agents/conversations/custom_history_provider.py index 59d63b70c0..674e056da2 100644 --- a/python/samples/02-agents/conversations/custom_history_provider.py +++ b/python/samples/02-agents/conversations/custom_history_provider.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Sequence from typing import Any -from agent_framework import Agent, AgentSession, BaseHistoryProvider, Message +from agent_framework import Agent, AgentSession, HistoryProvider, Message from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv @@ -20,7 +20,7 @@ preferred storage solution (database, file system, etc.). """ -class CustomHistoryProvider(BaseHistoryProvider): +class CustomHistoryProvider(HistoryProvider): """Implementation of custom history provider. In real applications, this can be an implementation of relational database or vector store.""" diff --git a/python/samples/02-agents/conversations/suspend_resume_session.py b/python/samples/02-agents/conversations/suspend_resume_session.py index a5e9c44248..457ffd9a6d 100644 --- a/python/samples/02-agents/conversations/suspend_resume_session.py +++ b/python/samples/02-agents/conversations/suspend_resume_session.py @@ -4,6 +4,7 @@ import asyncio from agent_framework import Agent, AgentSession from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -62,7 +63,7 @@ async def suspend_resume_in_memory_session() -> None: # OpenAI Chat Client is used as an example here, # other chat clients can be used as well. agent = Agent( - client=FoundryChatClient(), + client=OpenAIChatCompletionClient(), name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation.", ) diff --git a/python/samples/02-agents/declarative/README.md b/python/samples/02-agents/declarative/README.md index 045280218e..e1453e7d52 100644 --- a/python/samples/02-agents/declarative/README.md +++ b/python/samples/02-agents/declarative/README.md @@ -28,7 +28,7 @@ Demonstrates how to create an agent with custom function tools using the declara - Uses Azure OpenAI Responses client - Shows how to bind Python functions to the agent using the `bindings` parameter -- Loads agent configuration from `agent-samples/chatclient/GetWeather.yaml` +- Loads agent configuration from `declarative-agents/agent-samples/chatclient/GetWeather.yaml` - Implements a simple weather lookup function tool **Key concepts**: Function binding, Azure OpenAI integration, tool usage @@ -39,11 +39,11 @@ Shows how to create an agent that can search and retrieve information from Micro - Uses Azure AI Foundry client with MCP server integration - Demonstrates async context managers for proper resource cleanup -- Loads agent configuration from `agent-samples/foundry/MicrosoftLearnAgent.yaml` +- Loads agent configuration from `declarative-agents/agent-samples/foundry/MicrosoftLearnAgent.yaml` - Uses Azure CLI credentials for authentication - Leverages MCP to access Microsoft documentation tools -**Requirements**: `pip install agent-framework-foundry --pre` +**Requirements**: `pip install agent-framework-foundry` **Key concepts**: Azure AI Foundry integration, MCP server usage, async patterns, resource management @@ -53,7 +53,7 @@ Shows how to create an agent using an inline YAML string rather than a file. - Uses Azure AI Foundry v2 Client with instructions. -**Requirements**: `pip install agent-framework-foundry --pre` +**Requirements**: `pip install agent-framework-foundry` **Key concepts**: Inline YAML definition. @@ -63,29 +63,29 @@ Illustrates a basic agent using Azure OpenAI with structured responses. - Uses Azure OpenAI Responses client - Shows how to pass credentials via `client_kwargs` -- Loads agent configuration from `agent-samples/azure/AzureOpenAIResponses.yaml` +- Loads agent configuration from `declarative-agents/agent-samples/azure/AzureOpenAIResponses.yaml` - Demonstrates accessing structured response data **Key concepts**: Azure OpenAI integration, credential management, structured outputs -### 5. **OpenAI Responses Agent** ([`openai_responses_agent.py`](./openai_responses_agent.py)) +### 5. **OpenAI Responses Agent** ([`openai_agent.py`](./openai_agent.py)) Demonstrates the simplest possible agent using OpenAI directly. - Uses OpenAI API (requires `OPENAI_API_KEY` environment variable) - Shows minimal configuration needed for basic agent creation -- Loads agent configuration from `agent-samples/openai/OpenAIResponses.yaml` +- Loads agent configuration from `declarative-agents/agent-samples/openai/OpenAIResponses.yaml` **Key concepts**: OpenAI integration, minimal setup, environment-based configuration ## Agent Samples Repository -All the YAML configuration files referenced in these samples are located in the [`agent-samples`](../../../../agent-samples/) folder at the repository root. This folder contains declarative agent specifications organized by provider: +All the YAML configuration files referenced in these samples are located in the [`declarative-agents/agent-samples`](../../../../declarative-agents/agent-samples/) folder at the repository root. This folder contains declarative agent specifications organized by provider: -- **`agent-samples/azure/`** - Azure OpenAI agent configurations -- **`agent-samples/chatclient/`** - Chat client agent configurations with tools -- **`agent-samples/foundry/`** - Azure AI Foundry agent configurations -- **`agent-samples/openai/`** - OpenAI agent configurations +- **`declarative-agents/agent-samples/azure/`** - Azure OpenAI agent configurations +- **`declarative-agents/agent-samples/chatclient/`** - Chat client agent configurations with tools +- **`declarative-agents/agent-samples/foundry/`** - Azure AI Foundry agent configurations +- **`declarative-agents/agent-samples/openai/`** - OpenAI agent configurations **Important**: These YAML files are **platform-agnostic** and work with both Python and .NET implementations of the Agent Framework. You can use the exact same YAML definition to create agents in either language, making it easy to share agent configurations across different technology stacks. @@ -159,7 +159,7 @@ agent_factory = AgentFactory( "MyProvider": { "package": "my_custom_module", "name": "MyCustomChatClient", - "model_id_field": "model_id", + "model_field": "model", } } ) @@ -176,7 +176,7 @@ agent = agent_factory.create_agent_from_yaml_path(Path("custom_provider.yaml")) This allows you to extend the declarative framework with custom chat client implementations. The mapping requires: - **package**: The Python package/module to import from - **name**: The class name of your SupportsChatGetResponse implementation -- **model_id_field**: The constructor parameter name that accepts the value of the `model.id` field from the YAML +- **model_field**: The constructor parameter name that accepts the value of the `model.id` field from the YAML You can reference your custom provider using either `Provider.ApiType` format or just `Provider` in your YAML configuration, as long as it matches the registered mapping. @@ -261,12 +261,12 @@ python openai_responses_agent.py ## Learn More - [Agent Framework Declarative Package](../../../packages/declarative/) - Main declarative package documentation -- [Agent Samples](../../../../agent-samples/) - Additional declarative agent YAML specifications +- [Agent Samples](../../../../declarative-agents/agent-samples/) - Additional declarative agent YAML specifications - [Agent Framework Core](../../../packages/core/) - Core agent framework documentation ## Next Steps -1. Explore the YAML files in the `agent-samples` folder to understand the configuration format +1. Explore the YAML files in the `declarative-agents/agent-samples` folder to understand the configuration format 2. Try modifying the samples to use different models or instructions 3. Create your own declarative agent configurations 4. Build custom function tools and bind them to your agents diff --git a/python/samples/02-agents/declarative/azure_openai_responses_agent.py b/python/samples/02-agents/declarative/azure_openai_responses_agent.py index a8988e8311..7a02c3a53d 100644 --- a/python/samples/02-agents/declarative/azure_openai_responses_agent.py +++ b/python/samples/02-agents/declarative/azure_openai_responses_agent.py @@ -14,7 +14,13 @@ async def main(): """Create an agent from a declarative yaml specification and run it.""" # get the path current_path = Path(__file__).parent - yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "azure" / "AzureOpenAIResponses.yaml" + yaml_path = ( + current_path.parent.parent.parent.parent + / "declarative-agents" + / "agent-samples" + / "azure" + / "AzureOpenAIResponses.yaml" + ) # load the yaml from the path with yaml_path.open("r") as f: yaml_str = f.read() diff --git a/python/samples/02-agents/declarative/get_weather_agent.py b/python/samples/02-agents/declarative/get_weather_agent.py index 30a85de83d..966ea2b734 100644 --- a/python/samples/02-agents/declarative/get_weather_agent.py +++ b/python/samples/02-agents/declarative/get_weather_agent.py @@ -22,7 +22,13 @@ async def main(): """Create an agent from a declarative yaml specification and run it.""" # get the path current_path = Path(__file__).parent - yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "chatclient" / "GetWeather.yaml" + yaml_path = ( + current_path.parent.parent.parent.parent + / "declarative-agents" + / "agent-samples" + / "chatclient" + / "GetWeather.yaml" + ) # load the yaml from the path with yaml_path.open("r") as f: yaml_str = f.read() diff --git a/python/samples/02-agents/declarative/inline_yaml.py b/python/samples/02-agents/declarative/inline_yaml.py index 016f75947f..de6ad8fb4f 100644 --- a/python/samples/02-agents/declarative/inline_yaml.py +++ b/python/samples/02-agents/declarative/inline_yaml.py @@ -18,7 +18,7 @@ Prerequisites: - `pip install agent-framework-foundry agent-framework-declarative --pre` - Set the following environment variables in a .env file or your environment: - FOUNDRY_PROJECT_ENDPOINT - - AZURE_OPENAI_MODEL + - FOUNDRY_MODEL """ @@ -31,7 +31,7 @@ instructions: Specialized diagnostic and issue detection agent for systems with description: A agent that performs diagnostics on systems and can escalate issues when critical errors are detected. model: - id: =Env.AZURE_OPENAI_MODEL + id: =Env.FOUNDRY_MODEL """ # create the agent from the yaml async with ( diff --git a/python/samples/02-agents/declarative/mcp_tool_yaml.py b/python/samples/02-agents/declarative/mcp_tool_yaml.py index fd6c233034..3931d19ae1 100644 --- a/python/samples/02-agents/declarative/mcp_tool_yaml.py +++ b/python/samples/02-agents/declarative/mcp_tool_yaml.py @@ -10,11 +10,11 @@ Key Features Demonstrated: 1. Loading agent definitions from YAML using AgentFactory 2. Configuring MCP tools with different authentication methods: - API key authentication (OpenAI.Responses provider) - - Azure AI Foundry connection references (AzureAI.ProjectProvider) + - Azure AI Foundry connection references (Foundry provider) Authentication Options: - OpenAI.Responses: Supports inline API key auth via headers -- AzureAI.ProjectProvider: Uses Foundry connections for secure credential storage +- Foundry: Uses project-backed chat with Foundry connections for secure credential storage (no secrets passed in API calls - connection name references pre-configured auth) Prerequisites: @@ -79,7 +79,7 @@ instructions: | model: id: gpt-4o - provider: AzureAI.ProjectProvider + provider: Foundry tools: - kind: mcp diff --git a/python/samples/02-agents/declarative/microsoft_learn_agent.py b/python/samples/02-agents/declarative/microsoft_learn_agent.py index fc5994da21..b35965b173 100644 --- a/python/samples/02-agents/declarative/microsoft_learn_agent.py +++ b/python/samples/02-agents/declarative/microsoft_learn_agent.py @@ -9,12 +9,32 @@ from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() +""" +This sample demonstrates creating an agent from a declarative YAML file specification. + +It uses a MCP server to connect to the Microsoft Learn content and a FoundryChatClient. + +The yaml also has some chat options set, such as temperature and topP. +These options do not work with newer OpenAI models, so ensure to use a compatible model such as gpt-4o-mini. + +Environment variables: +- FOUNDRY_PROJECT_ENDPOINT: The endpoint URL for the Foundry project. +- FOUNDRY_MODEL: The model ID to use for the agent, make sure it is compatible with the chat options specified in + the yaml, or remove the options. +""" + async def main(): """Create an agent from a declarative yaml specification and run it.""" # get the path current_path = Path(__file__).parent - yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "foundry" / "MicrosoftLearnAgent.yaml" + yaml_path = ( + current_path.parent.parent.parent.parent + / "declarative-agents" + / "agent-samples" + / "foundry" + / "MicrosoftLearnAgent.yaml" + ) # create the agent from the yaml async with ( AzureCliCredential() as credential, diff --git a/python/samples/02-agents/declarative/openai_responses_agent.py b/python/samples/02-agents/declarative/openai_agent.py similarity index 79% rename from python/samples/02-agents/declarative/openai_responses_agent.py rename to python/samples/02-agents/declarative/openai_agent.py index 1a78c8ab7a..621805db4e 100644 --- a/python/samples/02-agents/declarative/openai_responses_agent.py +++ b/python/samples/02-agents/declarative/openai_agent.py @@ -13,12 +13,15 @@ async def main(): """Create an agent from a declarative yaml specification and run it.""" # get the path current_path = Path(__file__).parent - yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "openai" / "OpenAIResponses.yaml" - # load the yaml from the path - with yaml_path.open("r") as f: - yaml_str = f.read() + yaml_path = ( + current_path.parent.parent.parent.parent + / "declarative-agents" + / "agent-samples" + / "openai" + / "OpenAIResponses.yaml" + ) # create the agent from the yaml - agent = AgentFactory(safe_mode=False).create_agent_from_yaml(yaml_str) + agent = AgentFactory(safe_mode=False).create_agent_from_yaml_path(yaml_path) # use the agent response = await agent.run("Why is the sky blue, answer in Dutch?") # Use response.value with try/except for safe parsing diff --git a/python/samples/02-agents/devui/.env.example b/python/samples/02-agents/devui/.env.example new file mode 100644 index 0000000000..b52c21a4e8 --- /dev/null +++ b/python/samples/02-agents/devui/.env.example @@ -0,0 +1,15 @@ +# Shared configuration for samples/02-agents/devui +# Used by in_memory_mode.py, main.py, and as a fallback for discovered samples. +# Run `az login` before starting Azure-backed samples. + +# Microsoft Foundry samples +FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com +FOUNDRY_MODEL=gpt-4o + +# Azure OpenAI workflow sample +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +AZURE_OPENAI_CHAT_MODEL=gpt-4o +# Optional fallback env name also supported by workflow_with_agents/workflow.py: +AZURE_OPENAI_MODEL=gpt-4o +# Optional if you need to override the default API version: +AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/README.md b/python/samples/02-agents/devui/README.md index c5ce2095b8..970116c61a 100644 --- a/python/samples/02-agents/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -16,76 +16,124 @@ DevUI is a sample application that provides: ## Quick Start -### Option 1: In-Memory Mode (Simplest) +### Option 1: In-Memory Mode (Programmatic Registration) -Run a single sample directly. This demonstrates how to wrap agents and workflows programmatically without needing a directory structure: +Run a single sample directly. This demonstrates how to register agents and workflows in code without using DevUI's directory discovery. + +This sample uses Azure AI Foundry. Before running it: + +1. Copy `.env.example` in this folder to `.env`, or export the same values in your shell +2. Set `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` +3. Run `az login` + +Then start the sample: ```bash cd python/samples/02-agents/devui python in_memory_mode.py ``` -This opens your browser at http://localhost:8090 with pre-configured agents and a basic workflow. +This opens your browser at http://localhost:8090 with two Foundry-backed agents and a simple text transformation workflow. -### Option 2: Directory Discovery +### Option 2: Directory Discovery with Shared Root `.env` -Launch DevUI to discover all samples in this folder: +Run the folder-level launcher to load `samples/02-agents/devui/.env` and then start DevUI with directory discovery for this folder: ```bash cd python/samples/02-agents/devui -devui +python main.py ``` -This starts the server at http://localhost:8080 with all agents and workflows available. +This starts the server at http://localhost:8080 with all discoverable agents and workflows available. The root `.env` acts as shared fallback configuration for discovered samples. + +### Option 3: Directory Discovery with the `devui` CLI + +If you prefer the CLI directly, you can still launch DevUI from this folder: + +```bash +cd python/samples/02-agents/devui +devui . +``` + +DevUI discovery checks for a sample-specific `.env` first and then falls back to `.env` in `samples/02-agents/devui/`. ## Sample Structure -Each agent/workflow follows a strict structure required by DevUI's discovery system: +DevUI discovers samples from Python packages that export either `agent` or `workflow`. + +Typical agent layout: ``` agent_name/ -├── __init__.py # Must export: agent = Agent(...) +├── __init__.py # Must export: agent = ... ├── agent.py # Agent implementation -└── .env.example # Example environment variables +└── .env.example # Optional example environment variables +``` + +Typical workflow layout: + +``` +workflow_name/ +├── __init__.py # Must export: workflow = ... +├── workflow.py # Workflow implementation +├── workflow.yaml # Optional declarative definition +└── .env.example # Optional example environment variables ``` ## Available Samples ### Agents -| Sample | Description | Features | Required Environment Variables | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| [**weather_agent_azure/**](weather_agent_azure/) | Weather agent using Azure OpenAI with API key authentication | Azure OpenAI integration, function calling, mock weather tools | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | -| [**foundry_agent/**](foundry_agent/) | Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication (run `az login` first) | Azure AI Agent integration, Azure CLI authentication, mock weather tools | `AZURE_AI_PROJECT_ENDPOINT`, `FOUNDRY_MODEL_DEPLOYMENT_NAME` | +| Sample | What it demonstrates | Required keys / auth | +| ------ | -------------------- | -------------------- | +| [**agent_weather/**](agent_weather/) | A richer Foundry-backed weather agent that shows chat middleware, function middleware, tool calling, and an approval-required tool alongside auto-approved tools. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` | +| [**agent_foundry/**](agent_foundry/) | A minimal Foundry-backed weather agent with current weather and forecast tools. Use this when you want the smallest possible directory-discovered agent sample. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` | ### Workflows -| Sample | Description | Features | Required Environment Variables | -| -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| [**declarative/**](declarative/) | Declarative YAML workflow with conditional branching | YAML-based workflow definition, conditional logic, no Python code required | None - uses mock data | -| [**workflow_agents/**](workflow_agents/) | Content review workflow with agents as executors | Agents as workflow nodes, conditional routing based on structured outputs, quality-based paths (Writer -> Reviewer -> Editor/Publisher) | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | -| [**spam_workflow/**](spam_workflow/) | 5-step email spam detection workflow with branching logic | Sequential execution, conditional branching (spam vs. legitimate), multiple executors, mock spam detection | None - uses mock data | -| [**fanout_workflow/**](fanout_workflow/) | Advanced data processing workflow with parallel execution | Fan-out/fan-in patterns, complex state management, multi-stage processing (validation -> transformation -> quality assurance) | None - uses mock data | +| Sample | What it demonstrates | Required keys / auth | +| ------ | -------------------- | -------------------- | +| [**workflow_declarative/**](workflow_declarative/) | A YAML-defined workflow loaded through `WorkflowFactory`, with nested age-based branching and no model client code. | None | +| [**workflow_with_agents/**](workflow_with_agents/) | A content review workflow that uses agents as executors and routes based on structured review output (`Writer -> Reviewer -> Editor/Publisher -> Summarizer`). | `AZURE_OPENAI_ENDPOINT`, plus `AZURE_OPENAI_CHAT_MODEL` or `AZURE_OPENAI_MODEL`; Azure CLI auth via `az login`; `AZURE_OPENAI_API_VERSION` is optional | +| [**workflow_spam/**](workflow_spam/) | A multi-step spam detection workflow with human-in-the-loop approval, branching for spam vs. legitimate messages, and a final reporting step. | None | +| [**workflow_fanout/**](workflow_fanout/) | A larger fan-out/fan-in data processing workflow with parallel validation, multiple transformations, QA, aggregation, and demo failure toggles. | None | ### Standalone Examples -| Sample | Description | Features | -| ------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| [**in_memory_mode.py**](in_memory_mode.py) | Demonstrates programmatic entity registration without directory structure | In-memory agent and workflow registration, multiple entities served from a single file, includes basic workflow, simplest way to get started | +| Sample | What it demonstrates | Required keys / auth | +| ------ | -------------------- | -------------------- | +| [**in_memory_mode.py**](in_memory_mode.py) | Registers multiple entities directly in Python: two Foundry-backed agents plus a simple workflow, all served from one file without directory discovery. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` | ## Environment Variables -Each sample that requires API keys includes a `.env.example` file. To use: +For samples that require external services: -1. Copy `.env.example` to `.env` in the same directory -2. Fill in your actual API keys -3. DevUI automatically loads `.env` files from entity directories +1. Copy `.env.example` to `.env` +2. Fill in the required values +3. Run `az login` for samples that use Azure CLI authentication + +Directory discovery checks `.env` files in this order: + +1. The entity directory itself, for example `agent_weather/.env` +2. The root DevUI samples folder, `samples/02-agents/devui/.env` + +That means the root `.env.example` can hold shared defaults for multiple samples, while a sample-specific `.env` can override those values when needed. + +`in_memory_mode.py` and `main.py` both load `.env` from `samples/02-agents/devui/`, so the root `.env.example` in this folder is the right starting point for both commands. Alternatively, set environment variables globally: ```bash -export OPENAI_API_KEY="your-key-here" -export OPENAI_CHAT_MODEL="gpt-4o" +# Foundry-backed samples +export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com" +export FOUNDRY_MODEL="gpt-4o" + +# Azure OpenAI workflow_with_agents sample +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" +export AZURE_OPENAI_CHAT_MODEL="gpt-4o" +export AZURE_OPENAI_MODEL="gpt-4o" + +az login ``` ## Using DevUI with Your Own Agents @@ -145,7 +193,7 @@ curl http://localhost:8080/v1/entities ## Troubleshooting -**Missing API keys**: Check your `.env` files or environment variables. +**Missing credentials or settings**: Check your `.env` files, confirm the required variables for the sample you are running, and make sure `az login` has completed for Azure-authenticated samples. **Import errors**: Make sure you've installed the devui package: diff --git a/python/samples/02-agents/devui/agent_foundry/.env.example b/python/samples/02-agents/devui/agent_foundry/.env.example new file mode 100644 index 0000000000..c58831e971 --- /dev/null +++ b/python/samples/02-agents/devui/agent_foundry/.env.example @@ -0,0 +1,5 @@ +# Azure AI Foundry Configuration +# Make sure to run 'az login' before starting devui + +FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com +FOUNDRY_MODEL=gpt-4o diff --git a/python/samples/02-agents/devui/foundry_agent/__init__.py b/python/samples/02-agents/devui/agent_foundry/__init__.py similarity index 100% rename from python/samples/02-agents/devui/foundry_agent/__init__.py rename to python/samples/02-agents/devui/agent_foundry/__init__.py diff --git a/python/samples/02-agents/devui/foundry_agent/agent.py b/python/samples/02-agents/devui/agent_foundry/agent.py similarity index 97% rename from python/samples/02-agents/devui/foundry_agent/agent.py rename to python/samples/02-agents/devui/agent_foundry/agent.py index 1eb7feb9e2..eaeb316c2b 100644 --- a/python/samples/02-agents/devui/foundry_agent/agent.py +++ b/python/samples/02-agents/devui/agent_foundry/agent.py @@ -53,7 +53,7 @@ agent = Agent( name="FoundryWeatherAgent", client=FoundryChatClient( project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), - model_model=os.environ.get("FOUNDRY_MODEL_DEPLOYMENT_NAME"), + model=os.environ.get("FOUNDRY_MODEL"), credential=AzureCliCredential(), ), instructions=""" diff --git a/python/samples/02-agents/devui/agent_weather/.env.example b/python/samples/02-agents/devui/agent_weather/.env.example new file mode 100644 index 0000000000..c58831e971 --- /dev/null +++ b/python/samples/02-agents/devui/agent_weather/.env.example @@ -0,0 +1,5 @@ +# Azure AI Foundry Configuration +# Make sure to run 'az login' before starting devui + +FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com +FOUNDRY_MODEL=gpt-4o diff --git a/python/samples/02-agents/devui/weather_agent_azure/__init__.py b/python/samples/02-agents/devui/agent_weather/__init__.py similarity index 100% rename from python/samples/02-agents/devui/weather_agent_azure/__init__.py rename to python/samples/02-agents/devui/agent_weather/__init__.py diff --git a/python/samples/02-agents/devui/weather_agent_azure/agent.py b/python/samples/02-agents/devui/agent_weather/agent.py similarity index 92% rename from python/samples/02-agents/devui/weather_agent_azure/agent.py rename to python/samples/02-agents/devui/agent_weather/agent.py index 4861d7a4b8..201053ab92 100644 --- a/python/samples/02-agents/devui/weather_agent_azure/agent.py +++ b/python/samples/02-agents/devui/agent_weather/agent.py @@ -22,6 +22,7 @@ from agent_framework import ( ) from agent_framework.foundry import FoundryChatClient from agent_framework_devui import register_cleanup +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -67,7 +68,7 @@ async def security_filter_middleware( role="assistant", ) - response = ChatResponse(messages=[Message(role="assistant", text=error_message)]) + response = ChatResponse(messages=[Message(role="assistant", contents=[error_message])]) context.result = ResponseStream(blocked_stream(), finalizer=lambda _, r=response: r) else: # Non-streaming mode: return complete response @@ -75,7 +76,7 @@ async def security_filter_middleware( messages=[ Message( role="assistant", - text=error_message, + contents=[error_message], ) ] ) @@ -145,7 +146,7 @@ def send_email( # Agent instance following Agent Framework conventions agent = Agent( - name="AzureWeatherAgent", + name="WeatherAgent", description="A helpful agent that provides weather information and forecasts", instructions=""" You are a weather assistant. You can provide current weather information @@ -153,7 +154,9 @@ agent = Agent( weather information when asked. """, client=FoundryChatClient( - api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""), + project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), + model=os.environ.get("FOUNDRY_MODEL"), + credential=AzureCliCredential(), ), tools=[get_weather, get_forecast, send_email], middleware=[security_filter_middleware, atlantis_location_filter_middleware], @@ -164,7 +167,7 @@ register_cleanup(agent, cleanup_resources) def main(): - """Launch the Azure weather agent in DevUI.""" + """Launch the Weather Agent in DevUI.""" import logging from agent_framework.devui import serve @@ -173,9 +176,9 @@ def main(): logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger(__name__) - logger.info("Starting Azure Weather Agent") + logger.info("Starting Weather Agent") logger.info("Available at: http://localhost:8090") - logger.info("Entity ID: agent_AzureWeatherAgent") + logger.info("Entity ID: agent_WeatherAgent") # Launch server with the agent serve(entities=[agent], port=8090, auto_open=True) diff --git a/python/samples/02-agents/devui/azure_responses_agent/.env.example b/python/samples/02-agents/devui/azure_responses_agent/.env.example deleted file mode 100644 index 4d0751a863..0000000000 --- a/python/samples/02-agents/devui/azure_responses_agent/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Azure OpenAI Responses API Configuration -# The Responses API supports PDF uploads, images, and other multimodal content. -# Requires api-version 2025-03-01-preview or later. - -# Option 1: Use API key authentication -AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here - -# Option 2: Use Azure CLI authentication (run 'az login' first) -# No API key needed - just leave AZURE_OPENAI_API_KEY unset - -# Required: Azure OpenAI endpoint with Responses API support -AZURE_OPENAI_ENDPOINT=https://your-resource.cognitiveservices.azure.com/ - -# Required: Deployment name (must support Responses API) -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1-mini diff --git a/python/samples/02-agents/devui/azure_responses_agent/__init__.py b/python/samples/02-agents/devui/azure_responses_agent/__init__.py deleted file mode 100644 index f72521a7af..0000000000 --- a/python/samples/02-agents/devui/azure_responses_agent/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Azure Responses Agent sample for DevUI.""" - -from .agent import agent - -__all__ = ["agent"] diff --git a/python/samples/02-agents/devui/azure_responses_agent/agent.py b/python/samples/02-agents/devui/azure_responses_agent/agent.py deleted file mode 100644 index bb6bda6cf6..0000000000 --- a/python/samples/02-agents/devui/azure_responses_agent/agent.py +++ /dev/null @@ -1,128 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Sample agent using Azure OpenAI Responses API for Agent Framework DevUI. - -This agent uses the Responses API which supports: -- PDF file uploads -- Image uploads -- Audio inputs -- And other multimodal content - -The Chat Completions API (FoundryChatClient) does NOT support PDF uploads. -Use this agent when you need to process documents or other file types. - -Required environment variables: -- AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint -- FOUNDRY_MODEL: Deployment name for Responses API - (falls back to FOUNDRY_MODEL if not set) -- AZURE_OPENAI_API_KEY: Your API key (or use Azure CLI auth) -""" - -import logging -import os -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -logger = logging.getLogger(__name__) - -# Get deployment name - try responses-specific env var first, fall back to chat deployment -_deployment_name = os.environ.get( - "FOUNDRY_MODEL", - os.environ.get("FOUNDRY_MODEL", ""), -) - -# Get endpoint - try responses-specific env var first, fall back to default -_endpoint = os.environ.get( - "AZURE_OPENAI_RESPONSES_ENDPOINT", - os.environ.get("AZURE_OPENAI_ENDPOINT", ""), -) - - -def analyze_content( - query: Annotated[str, "What to analyze or extract from the uploaded content"], -) -> str: - """Analyze uploaded content based on the user's query. - - This is a placeholder - the actual analysis is done by the model - when processing the uploaded files. - """ - return f"Analyzing content for: {query}" - - -# 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 summarize_document( - length: Annotated[str, "Desired summary length: 'brief', 'medium', or 'detailed'"] = "medium", -) -> str: - """Generate a summary of the uploaded document.""" - return f"Generating {length} summary of the document..." - - -@tool(approval_mode="never_require") -def extract_key_points( - max_points: Annotated[int, "Maximum number of key points to extract"] = 5, -) -> str: - """Extract key points from the uploaded document.""" - return f"Extracting up to {max_points} key points..." - - -# Agent using Azure OpenAI Responses API (supports PDF uploads!) -agent = Agent( - name="AzureResponsesAgent", - description="An agent that can analyze PDFs, images, and other documents using Azure OpenAI Responses API", - instructions=""" - You are a helpful document analysis assistant. You can: - - 1. Analyze uploaded PDF documents and extract information - 2. Summarize document contents - 3. Answer questions about uploaded files - 4. Extract key points and insights - - When a user uploads a file, carefully analyze its contents and provide - helpful, accurate information based on what you find. - - For PDFs, you can read and understand the text, tables, and structure. - For images, you can describe what you see and extract any text. - """, - client=FoundryChatClient( - model=_deployment_name, - endpoint=_endpoint, - api_version="2025-03-01-preview", # Required for Responses API - ), - tools=[summarize_document, extract_key_points], -) - - -def main(): - """Launch the Azure Responses agent in DevUI.""" - from agent_framework_devui import serve - - logging.basicConfig(level=logging.INFO, format="%(message)s") - - logger.info("=" * 60) - logger.info("Starting Azure Responses Agent") - logger.info("=" * 60) - logger.info("") - logger.info("This agent uses the Azure OpenAI Responses API which supports:") - logger.info(" - PDF file uploads") - logger.info(" - Image uploads") - logger.info(" - Audio inputs") - logger.info("") - logger.info("Try uploading a PDF and asking questions about it!") - logger.info("") - logger.info("Required environment variables:") - logger.info(" - AZURE_OPENAI_ENDPOINT") - logger.info(" - FOUNDRY_MODEL") - logger.info(" - AZURE_OPENAI_API_KEY (or use Azure CLI auth)") - logger.info("") - - serve(entities=[agent], port=8090, auto_open=True) - - -if __name__ == "__main__": - main() diff --git a/python/samples/02-agents/devui/foundry_agent/.env.example b/python/samples/02-agents/devui/foundry_agent/.env.example deleted file mode 100644 index 79a6108b53..0000000000 --- a/python/samples/02-agents/devui/foundry_agent/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Azure AI Foundry Configuration -# Get your credentials from Azure AI Foundry portal -# Make sure to run 'az login' before starting devui - -AZURE_AI_PROJECT_ENDPOINT=https://your-project.api.azureml.ms -FOUNDRY_MODEL_DEPLOYMENT_NAME=gpt-4o diff --git a/python/samples/02-agents/devui/in_memory_mode.py b/python/samples/02-agents/devui/in_memory_mode.py index 9aba93b3e6..13c6c55f5d 100644 --- a/python/samples/02-agents/devui/in_memory_mode.py +++ b/python/samples/02-agents/devui/in_memory_mode.py @@ -20,6 +20,7 @@ from agent_framework import ( ) from agent_framework.devui import serve from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from typing_extensions import Never @@ -80,14 +81,13 @@ def main(): # Create Azure OpenAI chat client client = FoundryChatClient( - api_key=os.environ.get("AZURE_OPENAI_API_KEY"), model=os.environ["FOUNDRY_MODEL"], - endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), - api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21"), + project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), + credential=AzureCliCredential(), ) # Create agents - weather_agent = Agent( + weather_assistant = Agent( name="weather-assistant", description="Provides weather information and time", instructions=( @@ -120,7 +120,7 @@ def main(): ) # Collect entities for serving - entities = [weather_agent, simple_agent, basic_workflow] + entities = [weather_assistant, simple_agent, basic_workflow] logger.info("Starting DevUI on http://localhost:8090") logger.info("Entities available:") diff --git a/python/samples/02-agents/devui/main.py b/python/samples/02-agents/devui/main.py new file mode 100644 index 0000000000..1280ad24d4 --- /dev/null +++ b/python/samples/02-agents/devui/main.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Launch DevUI with folder discovery for the samples in this directory. + +This sample demonstrates: +- Loading a shared root `.env` file for the DevUI samples folder +- Starting DevUI in directory discovery mode for this folder +- Using root-level settings as fallbacks for discovered samples +""" + +from pathlib import Path + +from agent_framework.devui import serve +from dotenv import load_dotenv + + +def main() -> None: + """Load the root .env file and launch DevUI with folder discovery.""" + samples_dir = Path(__file__).resolve().parent + + # 1. Load shared defaults for the samples in this folder. + load_dotenv(samples_dir / ".env") + + # 2. Start DevUI and discover entities from this directory. + serve(entities_dir=str(samples_dir), auto_open=True) + + +if __name__ == "__main__": + main() + +# Sample output: +# Starting Agent Framework DevUI on 127.0.0.1:8080 diff --git a/python/samples/02-agents/devui/weather_agent_azure/.env.example b/python/samples/02-agents/devui/weather_agent_azure/.env.example deleted file mode 100644 index ed48950be0..0000000000 --- a/python/samples/02-agents/devui/weather_agent_azure/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Azure OpenAI API Configuration -# Get your credentials from Azure Portal - -AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4o -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com diff --git a/python/samples/02-agents/devui/workflow_agents/.env.example b/python/samples/02-agents/devui/workflow_agents/.env.example deleted file mode 100644 index 98243da83e..0000000000 --- a/python/samples/02-agents/devui/workflow_agents/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Azure OpenAI API Configuration -# Get your credentials from Azure Portal - -AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4o -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com -AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/declarative/__init__.py b/python/samples/02-agents/devui/workflow_declarative/__init__.py similarity index 100% rename from python/samples/02-agents/devui/declarative/__init__.py rename to python/samples/02-agents/devui/workflow_declarative/__init__.py diff --git a/python/samples/02-agents/devui/declarative/workflow.py b/python/samples/02-agents/devui/workflow_declarative/workflow.py similarity index 100% rename from python/samples/02-agents/devui/declarative/workflow.py rename to python/samples/02-agents/devui/workflow_declarative/workflow.py diff --git a/python/samples/02-agents/devui/declarative/workflow.yaml b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml similarity index 100% rename from python/samples/02-agents/devui/declarative/workflow.yaml rename to python/samples/02-agents/devui/workflow_declarative/workflow.yaml diff --git a/python/samples/02-agents/devui/fanout_workflow/__init__.py b/python/samples/02-agents/devui/workflow_fanout/__init__.py similarity index 100% rename from python/samples/02-agents/devui/fanout_workflow/__init__.py rename to python/samples/02-agents/devui/workflow_fanout/__init__.py diff --git a/python/samples/02-agents/devui/fanout_workflow/workflow.py b/python/samples/02-agents/devui/workflow_fanout/workflow.py similarity index 100% rename from python/samples/02-agents/devui/fanout_workflow/workflow.py rename to python/samples/02-agents/devui/workflow_fanout/workflow.py diff --git a/python/samples/02-agents/devui/spam_workflow/__init__.py b/python/samples/02-agents/devui/workflow_spam/__init__.py similarity index 100% rename from python/samples/02-agents/devui/spam_workflow/__init__.py rename to python/samples/02-agents/devui/workflow_spam/__init__.py diff --git a/python/samples/02-agents/devui/spam_workflow/workflow.py b/python/samples/02-agents/devui/workflow_spam/workflow.py similarity index 100% rename from python/samples/02-agents/devui/spam_workflow/workflow.py rename to python/samples/02-agents/devui/workflow_spam/workflow.py diff --git a/python/samples/02-agents/devui/workflow_with_agents/.env.example b/python/samples/02-agents/devui/workflow_with_agents/.env.example new file mode 100644 index 0000000000..54eb5d179a --- /dev/null +++ b/python/samples/02-agents/devui/workflow_with_agents/.env.example @@ -0,0 +1,9 @@ +# Azure OpenAI configuration for the Responses-based workflow sample +# This sample uses Azure CLI auth, so run `az login` before starting DevUI. + +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +AZURE_OPENAI_CHAT_MODEL=gpt-4o +# Optional fallback env name also supported by the client: +# AZURE_OPENAI_MODEL=gpt-4o +# Optional if you need to override the default API version: +AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/workflow_agents/__init__.py b/python/samples/02-agents/devui/workflow_with_agents/__init__.py similarity index 100% rename from python/samples/02-agents/devui/workflow_agents/__init__.py rename to python/samples/02-agents/devui/workflow_with_agents/__init__.py diff --git a/python/samples/02-agents/devui/workflow_agents/workflow.py b/python/samples/02-agents/devui/workflow_with_agents/workflow.py similarity index 93% rename from python/samples/02-agents/devui/workflow_agents/workflow.py rename to python/samples/02-agents/devui/workflow_with_agents/workflow.py index 750b8ac45f..708c85a63c 100644 --- a/python/samples/02-agents/devui/workflow_agents/workflow.py +++ b/python/samples/02-agents/devui/workflow_with_agents/workflow.py @@ -18,7 +18,8 @@ import os from typing import Any from agent_framework import Agent, AgentExecutorResponse, WorkflowBuilder -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel @@ -62,8 +63,13 @@ def is_approved(message: Any) -> bool: return True -# Create Azure OpenAI chat client -client = FoundryChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", "")) +# Create Azure OpenAI Responses chat client +client = OpenAIChatClient( + model=os.environ.get("AZURE_OPENAI_CHAT_MODEL") or os.environ.get("AZURE_OPENAI_MODEL"), + azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), + api_version=os.environ.get("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), +) # Create Writer agent - generates content writer = Agent( diff --git a/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py b/python/samples/02-agents/embeddings/foundry_embeddings.py similarity index 57% rename from python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py rename to python/samples/02-agents/embeddings/foundry_embeddings.py index 70d60983e9..b52443249c 100644 --- a/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py +++ b/python/samples/02-agents/embeddings/foundry_embeddings.py @@ -1,10 +1,10 @@ # /// script # requires-python = ">=3.10" # dependencies = [ -# "agent-framework-azure-ai", +# "agent-framework-foundry", # ] # /// -# Run with: uv run samples/02-agents/embeddings/azure_ai_inference_embeddings.py +# Run with: uv run samples/02-agents/embeddings/foundry_embeddings.py # Copyright (c) Microsoft. All rights reserved. @@ -12,24 +12,29 @@ import asyncio import pathlib from agent_framework import Content -from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient +from agent_framework.foundry import FoundryEmbeddingClient from dotenv import load_dotenv load_dotenv() -"""Azure AI Inference Image Embedding Example +"""Microsoft Foundry Image Embedding Example This sample demonstrates how to generate image embeddings using the -Azure AI Inference embedding client with the Cohere-embed-v3-english model. +Foundry embedding client with the Cohere-embed-v3-english model. Images are passed as ``Content`` objects created with ``Content.from_data()``. Prerequisites: - Set the following environment variables or add them to a .env file: - - AZURE_AI_INFERENCE_ENDPOINT: Your Azure AI model inference endpoint URL - - AZURE_AI_INFERENCE_API_KEY: Your API key - - AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID: The text embedding model name + Deploy an embedding model to a Foundry-hosted inference endpoint that supports image inputs, + such as Cohere-embed-v3-english. + + The details page for that model, has a target URI and a Key, which should be set in environment variables or a .env + file as follows, the target URI should append the `/models` path: + - FOUNDRY_MODELS_ENDPOINT: Your Foundry models endpoint URL, for instance: + https://.azure-api.net//models + - FOUNDRY_MODELS_API_KEY: Your API key + - FOUNDRY_EMBEDDING_MODEL: The text embedding model name (e.g. "text-embedding-3-small") - - AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID: The image embedding model name + - FOUNDRY_IMAGE_EMBEDDING_MODEL: The image embedding model name (e.g. "Cohere-embed-v3-english") """ @@ -37,15 +42,15 @@ SAMPLE_IMAGE_PATH = pathlib.Path(__file__).parent.parent.parent / "shared" / "sa async def main() -> None: - """Generate image embeddings with Azure AI Inference.""" - async with AzureAIInferenceEmbeddingClient() as client: + """Generate image embeddings with Foundry.""" + async with FoundryEmbeddingClient() as client: # 1. Generate an image embedding. image_bytes = SAMPLE_IMAGE_PATH.read_bytes() image_content = Content.from_data(data=image_bytes, media_type="image/jpeg") result = await client.get_embeddings([image_content]) print(f"Image embedding dimensions: {result[0].dimensions}") print(f"First 5 values: {result[0].vector[:5]}") - print(f"Model: {result[0].model_id}") + print(f"Model: {result[0].model}") print(f"Usage: {result.usage}") print() @@ -73,15 +78,17 @@ if __name__ == "__main__": """ -Sample output (using Cohere-embed-v3-english): +Sample output (using deployment: Cohere-embed-v3-english, which is Cohere's "embed-english-v3.0-image" model): Image embedding dimensions: 1024 -First 5 values: [0.023, -0.045, 0.067, -0.089, 0.011] -Model: Cohere-embed-v3-english -Usage: {'prompt_tokens': 1, 'total_tokens': 1} +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] +Model: embed-english-v3.0-image +Usage: {'input_token_count': 1000, 'output_token_count': 0} -Image+text (separate) results: Text embedding dimensions: 1536 +First 5 values: [-0.019439403, 0.015791258, 0.012358093, 0.0028533707, -0.01649483] Image embedding dimensions: 1024 +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] Document embedding dimensions: 1024 +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] """ diff --git a/python/samples/02-agents/embeddings/azure_openai_embeddings.py b/python/samples/02-agents/embeddings/openai_embeddings_on_azure.py similarity index 93% rename from python/samples/02-agents/embeddings/azure_openai_embeddings.py rename to python/samples/02-agents/embeddings/openai_embeddings_on_azure.py index 460bbcf7de..30f3cf4569 100644 --- a/python/samples/02-agents/embeddings/azure_openai_embeddings.py +++ b/python/samples/02-agents/embeddings/openai_embeddings_on_azure.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv Prerequisites: Set the following environment variables or add them to a local ``.env`` file: - ``AZURE_OPENAI_ENDPOINT``: Your Azure OpenAI endpoint URL - - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``: The embedding deployment name + - ``AZURE_OPENAI_EMBEDDING_MODEL``: The embedding deployment name - ``AZURE_OPENAI_API_VERSION``: Optional API version override Sign in with ``az login`` before running the sample. @@ -27,7 +27,7 @@ async def main() -> None: """Generate embeddings with Azure OpenAI.""" async with AzureCliCredential() as credential: client = OpenAIEmbeddingClient( - model=os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_EMBEDDING_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=credential, diff --git a/python/samples/02-agents/evaluation/evaluate_multimodal.py b/python/samples/02-agents/evaluation/evaluate_multimodal.py index 5d456ef4dc..f51bfc77e7 100644 --- a/python/samples/02-agents/evaluation/evaluate_multimodal.py +++ b/python/samples/02-agents/evaluation/evaluate_multimodal.py @@ -22,7 +22,6 @@ from agent_framework import ( evaluator, ) - # -- Custom evaluators that inspect multimodal content -- diff --git a/python/samples/02-agents/evaluation/evaluate_with_expected.py b/python/samples/02-agents/evaluation/evaluate_with_expected.py index de44c4e7e9..a165593c18 100644 --- a/python/samples/02-agents/evaluation/evaluate_with_expected.py +++ b/python/samples/02-agents/evaluation/evaluate_with_expected.py @@ -41,7 +41,7 @@ def response_matches_expected(response: str, expected_output: str) -> float: async def main() -> None: client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/02-agents/feature_stage_introspection.py b/python/samples/02-agents/feature_stage_introspection.py new file mode 100644 index 0000000000..b6ccfff43c --- /dev/null +++ b/python/samples/02-agents/feature_stage_introspection.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Any + +import agent_framework + +"""Feature stage introspection. + +This sample demonstrates how to inspect feature lifecycle metadata on Agent +Framework APIs. + +The recommended minimal setup for consumers is: +1. Read `__feature_stage__` with `getattr(...)` to see whether an API is staged +2. Read `__feature_id__` with `getattr(...)` only when that metadata is present +3. Treat missing metadata as "no explicit feature-stage annotation" +4. Do not rely on `ExperimentalFeature` or `ReleaseCandidateFeature` membership + over time, since staged features may move or be removed as they advance + +This sample loops through the symbols exported from the root `agent_framework` +module and reports the ones that currently carry feature-stage metadata. +""" + + +def describe_api(name: str, api: Any) -> None: + """Print optional feature-stage metadata for one API.""" + feature_stage = getattr(api, "__feature_stage__", "released") + feature_id = getattr(api, "__feature_id__", None) + + print(f"{name}:") + print(f" feature_stage = {feature_stage!r}") + print(f" feature_id = {feature_id!r}") + + +def iter_staged_root_exports() -> list[tuple[str, Any]]: + """Return root exports that currently carry feature-stage metadata.""" + staged_root_symbols: list[tuple[str, Any]] = [] + for symbol_name in sorted(agent_framework.__all__): + symbol = getattr(agent_framework, symbol_name) + feature_stage = getattr(symbol, "__feature_stage__", None) + feature_id = getattr(symbol, "__feature_id__", None) + if feature_stage is None and feature_id is None: + continue + staged_root_symbols.append((symbol_name, symbol)) + return staged_root_symbols + + +async def main() -> None: + """Run the feature-stage introspection sample.""" + print("Feature stage introspection") + print("-" * 60) + + # 1. Loop through everything exported from the root module. + staged_root_symbols = iter_staged_root_exports() + + # 2. Show the root exports that currently carry feature-stage metadata. + if not staged_root_symbols: + print("No root exports currently carry feature-stage metadata.") + return + + print("Root exports with feature-stage metadata:") + for name, api in staged_root_symbols: + describe_api(name, api) + print() + + print("Root exports without metadata currently have no explicit feature-stage metadata.") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +Feature stage introspection +------------------------------------------------------------ +Root exports with feature-stage metadata: + +: + feature_stage = 'experimental' + feature_id = '' + +Root exports without metadata currently have no explicit feature-stage metadata. +""" diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index e07d63ddbd..f433b349dc 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -11,13 +11,15 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to | Sample | File | Description | |--------|------|-------------| | **Agent as MCP Server** | [`agent_as_mcp_server.py`](agent_as_mcp_server.py) | Shows how to expose an Agent Framework agent as an MCP server that other AI applications can connect to | -| **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers | +| **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers using `header_provider`, runtime invocation kwargs, and a command-line API key argument | | **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication | ## Prerequisites - `OPENAI_API_KEY` environment variable -- `OPENAI_RESPONSES_MODEL` environment variable +- `OPENAI_CHAT_MODEL` environment variable + +Run `mcp_api_key_auth.py` with the MCP API key as the first command-line argument. For `mcp_github_pat.py`: - `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens) diff --git a/python/samples/02-agents/mcp/agent_as_mcp_server.py b/python/samples/02-agents/mcp/agent_as_mcp_server.py index 97cbcf3f75..dd92c240fb 100644 --- a/python/samples/02-agents/mcp/agent_as_mcp_server.py +++ b/python/samples/02-agents/mcp/agent_as_mcp_server.py @@ -4,7 +4,7 @@ from typing import Annotated, Any import anyio from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -57,7 +57,7 @@ async def run() -> None: # Define an agent # Agent's name and description provide better context for AI model agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="RestaurantAgent", description="Answer questions about the menu.", tools=[get_specials, get_item_price], diff --git a/python/samples/02-agents/mcp/mcp_api_key_auth.py b/python/samples/02-agents/mcp/mcp_api_key_auth.py index 16748a593c..456db2878c 100644 --- a/python/samples/02-agents/mcp/mcp_api_key_auth.py +++ b/python/samples/02-agents/mcp/mcp_api_key_auth.py @@ -1,20 +1,31 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os +import sys from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv -from httpx import AsyncClient # Load environment variables from .env file load_dotenv() """ -MCP Authentication Example +MCP API Key Authentication Example -This example demonstrates how to authenticate with MCP servers using API key headers. +This sample demonstrates the runtime ``header_provider`` pattern for +``MCPStreamableHTTPTool``. The MCP tool derives authentication headers from +``function_invocation_kwargs`` passed to ``Agent.run(...)`` so the API key stays +in runtime context instead of being baked into a shared ``httpx.AsyncClient``. + +Replace the ``url`` parameter in the ``MCPStreamableHTTPTool`` with your authenticated server URL and +run the sample with your API key as a command-line argument: + python mcp_api_key_auth.py + +The ``header_provider`` here is just a simple lambda, but it can be a more complex function that retrieves and +formats headers as needed, allowing for flexible authentication schemes. +For more complex scenarios, you could implement token refresh logic or support multiple authentication methods +within the header provider function. For more authentication examples including OAuth 2.0 flows, see: - https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-auth-client @@ -22,44 +33,28 @@ For more authentication examples including OAuth 2.0 flows, see: """ -async def api_key_auth_example() -> None: - """Example of using API key authentication with MCP server.""" - # Configuration - mcp_server_url = os.getenv("MCP_SERVER_URL", "your-mcp-server-url") - api_key = os.getenv("MCP_API_KEY") +async def api_key_auth_example(api_key: str) -> None: + """Run an agent against an MCP server using runtime-provided API key headers.""" - # Create authentication headers - # Common patterns: - # - Bearer token: "Authorization": f"Bearer {api_key}" - # - API key header: "X-API-Key": api_key - # - Custom header: "Authorization": f"ApiKey {api_key}" - auth_headers = { - "Authorization": f"Bearer {api_key}", - } - - # Create HTTP client with authentication headers - http_client = AsyncClient(headers=auth_headers) - - # Create MCP tool with the configured HTTP client - async with ( - MCPStreamableHTTPTool( + async with Agent( + client=OpenAIChatClient(), + name="Agent", + instructions="You are a helpful assistant. Use your MCP tool when answering the user's question.", + tools=MCPStreamableHTTPTool( name="MCP tool", - description="MCP tool description", - url=mcp_server_url, - http_client=http_client, # Pass HTTP client with authentication headers - ) as mcp_tool, - Agent( - client=OpenAIResponsesClient(), - name="Agent", - instructions="You are a helpful assistant.", - tools=mcp_tool, - ) as agent, - ): - query = "What tools are available to you?" + description="MCP tool description.", + url="", + header_provider=lambda kwargs: {"Authorization": f"Bearer {kwargs['mcp_api_key']}"}, + ), + ) as agent: + query = "Use your MCP tool to tell me what tools are available to you." print(f"User: {query}") - result = await agent.run(query) + result = await agent.run( + query, + function_invocation_kwargs={"mcp_api_key": api_key}, + ) print(f"Agent: {result.text}") if __name__ == "__main__": - asyncio.run(api_key_auth_example()) + asyncio.run(api_key_auth_example(sys.argv[1])) diff --git a/python/samples/02-agents/mcp/mcp_github_pat.py b/python/samples/02-agents/mcp/mcp_github_pat.py index 63d70a344d..8c83d7c8e2 100644 --- a/python/samples/02-agents/mcp/mcp_github_pat.py +++ b/python/samples/02-agents/mcp/mcp_github_pat.py @@ -4,7 +4,7 @@ import asyncio import os from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv """ @@ -45,7 +45,7 @@ async def github_mcp_example() -> None: # 4. Create agent with the GitHub MCP tool using instance method # The MCP tool manages the connection to the MCP server and makes its tools available # Set approval_mode="never_require" to allow the MCP tool to execute without approval - client = OpenAIResponsesClient() + client = OpenAIChatClient() github_mcp_tool = client.get_mcp_tool( name="GitHub", url="https://api.githubcopilot.com/mcp/", diff --git a/python/samples/02-agents/middleware/README.md b/python/samples/02-agents/middleware/README.md index 5bd318575c..1ec5a1abab 100644 --- a/python/samples/02-agents/middleware/README.md +++ b/python/samples/02-agents/middleware/README.md @@ -13,19 +13,19 @@ This folder contains focused middleware samples for `Agent`, chat clients, tools | [`exception_handling_with_middleware.py`](./exception_handling_with_middleware.py) | Shows how middleware can handle failures and recover cleanly. | | [`function_based_middleware.py`](./function_based_middleware.py) | Shows function-based agent and function middleware. | | [`middleware_termination.py`](./middleware_termination.py) | Demonstrates stopping a middleware pipeline early. | -| [`override_result_with_middleware.py`](./override_result_with_middleware.py) | Shows how middleware can replace the normal result. | -| [`runtime_context_delegation.py`](./runtime_context_delegation.py) | Demonstrates delegating work with runtime context data. | +| [`override_result_with_middleware.py`](./override_result_with_middleware.py) | Shows how middleware can replace regular and streaming results, then post-process the final response. | +| [`runtime_context_delegation.py`](./runtime_context_delegation.py) | Demonstrates delegating arguments with runtime context data. | | [`session_behavior_middleware.py`](./session_behavior_middleware.py) | Shows how middleware interacts with session-backed runs. | | [`shared_state_middleware.py`](./shared_state_middleware.py) | Demonstrates sharing mutable state across middleware invocations. | | [`usage_tracking_middleware.py`](./usage_tracking_middleware.py) | Demonstrates one chat middleware function that tracks per-call usage in non-streaming and streaming tool-loop runs. | ## Running the usage tracking sample -The new usage tracking sample uses `OpenAIResponsesClient`, so set the usual OpenAI responses environment variables first: +The new usage tracking sample uses `OpenAIChatClient`, so set the usual OpenAI responses environment variables first: ```bash export OPENAI_API_KEY="your-openai-api-key" -export OPENAI_RESPONSES_MODEL="gpt-4.1-mini" +export OPENAI_CHAT_MODEL="gpt-4.1-mini" ``` Then run: diff --git a/python/samples/02-agents/middleware/chat_middleware.py b/python/samples/02-agents/middleware/chat_middleware.py index 8234e30776..e5604bbd6b 100644 --- a/python/samples/02-agents/middleware/chat_middleware.py +++ b/python/samples/02-agents/middleware/chat_middleware.py @@ -127,9 +127,13 @@ async def security_and_override_middleware( messages=[ Message( role="assistant", - text="I cannot process requests containing sensitive information. " - "Please rephrase your question without including passwords, secrets, or other " - "sensitive data.", + contents=[ + ( + "I cannot process requests containing sensitive information. " + "Please rephrase your question without including passwords, secrets, or other " + "sensitive data." + ) + ], ) ] ) diff --git a/python/samples/02-agents/middleware/middleware_termination.py b/python/samples/02-agents/middleware/middleware_termination.py index a5d8e00340..02d8d8cf53 100644 --- a/python/samples/02-agents/middleware/middleware_termination.py +++ b/python/samples/02-agents/middleware/middleware_termination.py @@ -71,10 +71,12 @@ class PreTerminationMiddleware(AgentMiddleware): messages=[ Message( role="assistant", - text=( - f"Sorry, I cannot process requests containing '{blocked_word}'. " - "Please rephrase your question." - ), + contents=[ + ( + f"Sorry, I cannot process requests containing '{blocked_word}'. " + "Please rephrase your question." + ) + ], ) ] ) diff --git a/python/samples/02-agents/middleware/override_result_with_middleware.py b/python/samples/02-agents/middleware/override_result_with_middleware.py index 14bf42acd0..5e886290cf 100644 --- a/python/samples/02-agents/middleware/override_result_with_middleware.py +++ b/python/samples/02-agents/middleware/override_result_with_middleware.py @@ -19,7 +19,7 @@ from agent_framework import ( ResponseStream, tool, ) -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -81,12 +81,12 @@ async def weather_override_middleware(context: ChatContext, call_next: Callable[ role="assistant", ) - context.result = ResponseStream(_override_stream()) + context.result = ResponseStream(_override_stream(), finalizer=ChatResponse.from_updates) else: # For non-streaming: just replace with a new message current_text = context.result.text if isinstance(context.result, ChatResponse) else "" custom_message = f"Weather Advisory: [0] {''.join(chunks)} Original message was: {current_text}" - context.result = ChatResponse(messages=[Message(role="assistant", text=custom_message)]) + context.result = ChatResponse(messages=[Message(role="assistant", contents=[custom_message])]) async def validate_weather_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: @@ -99,14 +99,19 @@ async def validate_weather_middleware(context: ChatContext, call_next: Callable[ return if context.stream and isinstance(context.result, ResponseStream): + result_stream = context.result - def _append_validation_note(response: ChatResponse) -> ChatResponse: - response.messages.append(Message(role="assistant", text=validation_note)) - return response + async def _validated_stream() -> AsyncIterable[ChatResponseUpdate]: + async for update in result_stream: + yield update + yield ChatResponseUpdate( + contents=[Content.from_text(text=validation_note)], + role="assistant", + ) - context.result.with_finalizer(_append_validation_note) + context.result = ResponseStream(_validated_stream(), finalizer=ChatResponse.from_updates) elif isinstance(context.result, ChatResponse): - context.result.messages.append(Message(role="assistant", text=validation_note)) + context.result.messages.append(Message(role="assistant", contents=[validation_note])) async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: @@ -118,11 +123,11 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[] validation_note = "Validation: weather data verified." - state = {"found_prefix": False} + state = {"found_prefix": False, "found_validation": False} def _sanitize(response: AgentResponse) -> AgentResponse: found_prefix = state["found_prefix"] - found_validation = False + found_validation = state["found_validation"] cleaned_messages: list[Message] = [] for message in response.messages: @@ -141,12 +146,14 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[] found_prefix = True text = text.replace("Weather Advisory:", "") - text = re.sub(r"\[\d+\]\s*", "", text) + text = re.sub(r"\[\d+\]\s*", "", text).strip() + if not text: + continue cleaned_messages.append( Message( role=message.role, - text=text.strip(), + contents=[text], author_name=message.author_name, message_id=message.message_id, additional_properties=message.additional_properties, @@ -159,26 +166,37 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[] if not found_validation: raise RuntimeError("Expected validation note not found in agent response.") - cleaned_messages.append(Message(role="assistant", text=" Agent: OK")) + cleaned_messages.append(Message(role="assistant", contents=[" Agent: OK"])) response.messages = cleaned_messages return response if context.stream and isinstance(context.result, ResponseStream): def _clean_update(update: AgentResponseUpdate) -> AgentResponseUpdate: + cleaned_contents: list[Content] = [] + for content in update.contents or []: if not content.text: + cleaned_contents.append(content) continue text = content.text if "Weather Advisory:" in text: state["found_prefix"] = True text = text.replace("Weather Advisory:", "") + if validation_note in text: + state["found_validation"] = True + text = text.replace(validation_note, "").strip() + if not text: + continue text = re.sub(r"\[\d+\]\s*", "", text) content.text = text + cleaned_contents.append(content) + + update.contents = cleaned_contents return update context.result.with_transform_hook(_clean_update) - context.result.with_finalizer(_sanitize) + context.result.with_result_hook(_sanitize) elif isinstance(context.result, AgentResponse): context.result = _sanitize(context.result) @@ -190,7 +208,7 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=OpenAIResponsesClient( + client=OpenAIChatClient( middleware=[validate_weather_middleware, weather_override_middleware], ), name="WeatherAgent", diff --git a/python/samples/02-agents/middleware/runtime_context_delegation.py b/python/samples/02-agents/middleware/runtime_context_delegation.py index acc2219d6f..532fa56ba9 100644 --- a/python/samples/02-agents/middleware/runtime_context_delegation.py +++ b/python/samples/02-agents/middleware/runtime_context_delegation.py @@ -6,6 +6,7 @@ from typing import Annotated from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -43,6 +44,13 @@ Key Concepts: - MiddlewareTypes: Intercepts function calls to access/modify kwargs - Closure: Functions capturing variables from outer scope - kwargs Propagation: Automatic forwarding of runtime context through delegation chains + +Environment Setup: +- Configure Azure credentials (e.g., via Azure CLI) +- Run `az login` to authenticate +- Set FOUNDRY_PROJECT_ENDPOINT to your Azure AI Foundry project endpoint +- Set FOUNDRY_MODEL to the model deployment name (for example: gpt-4o) + """ @@ -85,7 +93,7 @@ class SessionContextContainer: runtime_context = SessionContextContainer() -# 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. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") async def send_email( to: Annotated[str, Field(description="Recipient email address")], @@ -149,7 +157,7 @@ async def pattern_1_single_agent_with_closure() -> None: print("Use case: Single agent with multiple tools sharing runtime context") print() - client = FoundryChatClient(model="gpt-4o-mini") + client = FoundryChatClient(credential=AzureCliCredential()) # Create agent with both tools and shared context via middleware communication_agent = Agent( @@ -177,9 +185,11 @@ async def pattern_1_single_agent_with_closure() -> None: result1 = await communication_agent.run( user_query, # Runtime context passed as kwargs - api_token="sk-test-token-xyz-789", - user_id="user-12345", - session_metadata={"tenant": "acme-corp", "region": "us-west"}, + function_invocation_kwargs={ + "api_token": "sk-test-token-xyz-789", + "user_id": "user-12345", + "session_metadata": {"tenant": "acme-corp", "region": "us-west"}, + }, ) print(f"\nAgent: {result1.text}") @@ -195,9 +205,11 @@ async def pattern_1_single_agent_with_closure() -> None: result2 = await communication_agent.run( user_query2, # Different runtime context for this request - api_token="sk-prod-token-abc-456", - user_id="user-67890", - session_metadata={"tenant": "store-inc", "region": "eu-central"}, + function_invocation_kwargs={ + "api_token": "sk-prod-token-abc-456", + "user_id": "user-67890", + "session_metadata": {"tenant": "store-inc", "region": "eu-central"}, + }, ) print(f"\nAgent: {result2.text}") @@ -215,9 +227,11 @@ async def pattern_1_single_agent_with_closure() -> None: result3 = await communication_agent.run( user_query3, - api_token="sk-dev-token-def-123", - user_id="user-11111", - session_metadata={"tenant": "dev-team", "region": "us-east"}, + function_invocation_kwargs={ + "api_token": "sk-dev-token-def-123", + "user_id": "user-11111", + "session_metadata": {"tenant": "dev-team", "region": "us-east"}, + }, ) print(f"\nAgent: {result3.text}") @@ -234,7 +248,9 @@ async def pattern_1_single_agent_with_closure() -> None: result4 = await communication_agent.run( user_query4, # Missing api_token - tools should handle gracefully - user_id="user-22222", + function_invocation_kwargs={ + "user_id": "user-22222", + }, ) print(f"\nAgent: {result4.text}") @@ -295,7 +311,7 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: print(f"[SMSAgent] Received runtime context: {list(context.kwargs.keys())}") await call_next() - client = FoundryChatClient(model="gpt-4o-mini") + client = FoundryChatClient(credential=AzureCliCredential()) # Create specialized sub-agents email_agent = Agent( @@ -341,9 +357,11 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: print("Test: Send email with runtime context\n") await coordinator.run( "Send an email to john@example.com with subject 'Meeting' and body 'See you at 2pm'", - api_token="secret-token-abc", - user_id="user-999", - tenant_id="tenant-acme", + function_invocation_kwargs={ + "api_token": "secret-token-abc", + "user_id": "user-999", + "tenant_id": "tenant-acme", + }, ) print(f"\n[Verification] EmailAgent received kwargs keys: {list(email_agent_kwargs.keys())}") @@ -400,7 +418,7 @@ async def pattern_3_hierarchical_with_middleware() -> None: auth_middleware = AuthContextMiddleware() - client = FoundryChatClient(model="gpt-4o-mini") + client = FoundryChatClient(credential=AzureCliCredential()) # Sub-agent with validation middleware protected_agent = Agent( @@ -428,16 +446,20 @@ async def pattern_3_hierarchical_with_middleware() -> None: print("Test 1: Valid token\n") await coordinator.run( "Execute operation: backup_database", - api_token="valid-token-xyz-789", - user_id="admin-123", + function_invocation_kwargs={ + "api_token": "valid-token-xyz-789", + "user_id": "admin-123", + }, ) # Test with invalid token print("\nTest 2: Invalid token\n") await coordinator.run( "Execute operation: delete_records", - api_token="invalid-token-bad", - user_id="user-456", + function_invocation_kwargs={ + "api_token": "invalid-token-bad", + "user_id": "user-456", + }, ) print(f"\n[Validation Summary] Validated tokens: {len(auth_middleware.validated_tokens)}") diff --git a/python/samples/02-agents/middleware/usage_tracking_middleware.py b/python/samples/02-agents/middleware/usage_tracking_middleware.py index bffa01f7d8..056d518fd4 100644 --- a/python/samples/02-agents/middleware/usage_tracking_middleware.py +++ b/python/samples/02-agents/middleware/usage_tracking_middleware.py @@ -19,7 +19,7 @@ from agent_framework import ( chat_middleware, tool, ) -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -53,7 +53,7 @@ def _reset_usage_counters() -> None: def _create_agent() -> Agent: """Create the shared agent used by both demonstrations.""" return Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions=( "You are a weather assistant. Always call the weather tool before answering weather questions, " "then summarize the tool result in one short paragraph." diff --git a/python/samples/02-agents/multimodal_input/README.md b/python/samples/02-agents/multimodal_input/README.md index 2254fe89f7..da99e71057 100644 --- a/python/samples/02-agents/multimodal_input/README.md +++ b/python/samples/02-agents/multimodal_input/README.md @@ -32,8 +32,8 @@ Set the following environment variables before running the examples: **For Azure OpenAI:** - `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint -- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat model deployment -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses model deployment +- `AZURE_OPENAI_MODEL`: The name of your Azure OpenAI chat model deployment +- `AZURE_OPENAI_MODEL`: The name of your Azure OpenAI responses model deployment Optionally for Azure OpenAI: - `AZURE_OPENAI_API_VERSION`: The API version to use (default is `2024-10-21`) @@ -41,11 +41,11 @@ Optionally for Azure OpenAI: **Note:** You can also provide configuration directly in code instead of using environment variables: ```python -# Example: Pass deployment_name directly -client = AzureOpenAIChatClient( +# Example: Pass the Foundry project endpoint directly +client = FoundryChatClient( credential=AzureCliCredential(), - deployment_name="your-deployment-name", - endpoint="https://your-resource.openai.azure.com" + project_endpoint="https://your-project.services.ai.azure.com", + model="your-deployment-name", ) ``` diff --git a/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py index 8a141fa9ce..2d7c5c9a20 100644 --- a/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py @@ -23,7 +23,7 @@ async def test_image() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. Requires AZURE_OPENAI_ENDPOINT and FOUNDRY_MODEL # environment variables to be set. - # Alternatively, you can pass deployment_name explicitly: + # Alternatively, you can pass model explicitly: # client = FoundryChatClient(credential=AzureCliCredential(), model="your-deployment-name") client = FoundryChatClient(credential=AzureCliCredential()) image_uri = create_sample_image() diff --git a/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py index 61f14749e7..ec0a684c47 100644 --- a/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py @@ -32,7 +32,7 @@ async def test_image() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. Requires AZURE_OPENAI_ENDPOINT and FOUNDRY_MODEL # environment variables to be set. - # Alternatively, you can pass deployment_name explicitly: + # Alternatively, you can pass model explicitly: # client = FoundryChatClient(credential=AzureCliCredential(), model="your-deployment-name") client = FoundryChatClient(credential=AzureCliCredential()) diff --git a/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py index 2879588a41..3fc8bae84d 100644 --- a/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py @@ -6,7 +6,7 @@ import struct from pathlib import Path from agent_framework import Content, Message -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file @@ -14,6 +14,13 @@ load_dotenv() ASSETS_DIR = Path(__file__).resolve().parents[2] / "shared" / "sample_assets" +""" +Leverage multimodel capabilities of different models. + +Uses the OpenAIChatClient and OpenAIChatCompletionClient to demonstrate multimodal input handling with the gpt-4o and gpt-4o-audio-preview models, respectively. The sample includes demonstrations for image, audio, and PDF inputs, showcasing how to create appropriate Content objects and send them in messages to the chat clients. + +""" + def load_sample_pdf() -> bytes: """Read the bundled sample PDF for tests.""" @@ -46,7 +53,7 @@ def create_sample_audio() -> str: async def test_image() -> None: """Test image analysis with OpenAI.""" - client = FoundryChatClient(model="gpt-4o") + client = OpenAIChatClient(model="gpt-4o") image_uri = create_sample_image() message = Message( @@ -63,7 +70,7 @@ async def test_image() -> None: async def test_audio() -> None: """Test audio analysis with OpenAI.""" - client = FoundryChatClient(model="gpt-4o-audio-preview") + client = OpenAIChatCompletionClient(model="gpt-4o-audio-preview-2025-06-03") audio_uri = create_sample_audio() message = Message( @@ -80,7 +87,7 @@ async def test_audio() -> None: async def test_pdf() -> None: """Test PDF document analysis with OpenAI.""" - client = FoundryChatClient(model="gpt-4o") + client = OpenAIChatClient(model="gpt-4o") pdf_bytes = load_sample_pdf() message = Message( diff --git a/python/samples/02-agents/observability/.env.example b/python/samples/02-agents/observability/.env.example index c1c24a5a72..f3dd329bac 100644 --- a/python/samples/02-agents/observability/.env.example +++ b/python/samples/02-agents/observability/.env.example @@ -40,10 +40,10 @@ ENABLE_SENSITIVE_DATA=true # OpenAI specific variables # ========================== OPENAI_API_KEY="..." -OPENAI_RESPONSES_MODEL="gpt-4o-2024-08-06" OPENAI_CHAT_MODEL="gpt-4o-2024-08-06" +OPENAI_CHAT_COMPLETION_MODEL="gpt-4o-2024-08-06" # Azure AI Foundry specific variables # ==================================== -AZURE_AI_PROJECT_ENDPOINT="..." -AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +FOUNDRY_PROJECT_ENDPOINT="..." +FOUNDRY_MODEL="gpt-4o-mini" diff --git a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py index f326733b5a..722cbf445a 100644 --- a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py +++ b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py @@ -8,11 +8,12 @@ from typing import Annotated from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import enable_instrumentation +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry._logs import set_logger_provider from opentelemetry.metrics import set_meter_provider from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler -from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogRecordExporter from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader from opentelemetry.sdk.resources import Resource @@ -37,7 +38,7 @@ def setup_logging(): # Create and set a global logger provider for the application. logger_provider = LoggerProvider(resource=resource) # Log processors are initialized with an exporter which is responsible - logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) + logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogRecordExporter())) # Sets the global default logger provider set_logger_provider(logger_provider) # Create a logging handler to write logging records, in OTLP format, to the exporter. @@ -115,11 +116,15 @@ async def run_chat_client() -> None: 2 spans with gen_ai.operation.name=execute_tool """ - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) message = "What's the weather in Amsterdam and in Paris?" print(f"User: {message}") print("Assistant: ", end="") - async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True): + async for chunk in client.get_response( + [Message(role="user", contents=[message])], + stream=True, + options={"tools": [get_weather]}, + ): if chunk.text: print(chunk.text, end="") print("") diff --git a/python/samples/02-agents/observability/advanced_zero_code.py b/python/samples/02-agents/observability/advanced_zero_code.py index 45696baa7b..dffd26a0fc 100644 --- a/python/samples/02-agents/observability/advanced_zero_code.py +++ b/python/samples/02-agents/observability/advanced_zero_code.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Annotated from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id @@ -90,12 +91,19 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals print(f"User: {message}") if stream: print("Assistant: ", end="") - async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True): + async for chunk in client.get_response( + [Message(role="user", contents=[message])], + stream=True, + options={"tools": [get_weather]}, + ): if chunk.text: print(chunk.text, end="") print("") else: - response = await client.get_response([Message(role="user", text=message)], tools=get_weather) + response = await client.get_response( + [Message(role="user", contents=[message])], + options={"tools": [get_weather]}, + ) print(f"Assistant: {response}") @@ -103,7 +111,7 @@ async def main() -> None: with get_tracer().start_as_current_span("Zero Code", kind=SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) await run_chat_client(client, stream=True) await run_chat_client(client, stream=False) diff --git a/python/samples/02-agents/observability/agent_observability.py b/python/samples/02-agents/observability/agent_observability.py index 695ff9a43f..5517b32933 100644 --- a/python/samples/02-agents/observability/agent_observability.py +++ b/python/samples/02-agents/observability/agent_observability.py @@ -7,6 +7,7 @@ from typing import Annotated from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id @@ -18,6 +19,12 @@ load_dotenv() """ This sample shows how you can observe an agent in Agent Framework by using the same observability setup function. + +Pre-requisites: +- A Foundry project +- An observability backend to receive traces and metrics (for example, a local or remote + OpenTelemetry Collector, another OTLP-compatible backend, or console exporters enabled + via environment variables). """ @@ -47,7 +54,7 @@ async def main(): print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") agent = Agent( - client=FoundryChatClient(), + client=FoundryChatClient(credential=AzureCliCredential()), tools=get_weather, name="WeatherAgent", instructions="You are a weather assistant.", diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py index 1400c8d839..9f96564c50 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Annotated, Literal from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry import trace from opentelemetry.trace.span import format_trace_id @@ -24,8 +25,9 @@ This sample shows how you can configure observability of an application via the When you run this sample with an OTLP endpoint or an Application Insights connection string, you should see traces, logs, and metrics in the configured backend. -If no OTLP endpoint or Application Insights connection string is configured, the sample will -output traces, logs, and metrics to the console. +Pre-requisites: +- A Foundry project +- A local OpenTelemetry Collector instance to receive the traces and metrics. """ # Load environment variables from .env file @@ -78,13 +80,18 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals if stream: print("Assistant: ", end="") async for chunk in client.get_response( - [Message(role="user", text=message)], tools=get_weather, stream=True + [Message(role="user", contents=[message])], + stream=True, + options={"tools": [get_weather]}, ): if chunk.text: print(chunk.text, end="") print("") else: - response = await client.get_response([Message(role="user", text=message)], tools=get_weather) + response = await client.get_response( + [Message(role="user", contents=[message])], + options={"tools": [get_weather]}, + ) print(f"Assistant: {response}") @@ -101,7 +108,7 @@ async def run_tool() -> None: with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT): print("Running scenario: AI Function") weather = await get_weather.invoke(location="Amsterdam") - print(f"Weather in Amsterdam:\n{weather}") + print(f"Weather in Amsterdam:\n{weather[-1]}") async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"): @@ -114,7 +121,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. if scenario == "tool" or scenario == "all": diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py index 6bf7a2fcd6..30c6b61331 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Annotated, Literal from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry import trace from opentelemetry.trace.span import format_trace_id @@ -27,6 +28,10 @@ and allows you to add multiple exporters programmatically. For standard OTLP setup, it's recommended to use environment variables (see configure_otel_providers_with_env_var.py). Use this approach when you need custom exporter configuration beyond what environment variables provide. + +Pre-requisites: +- A Foundry project +- A local OpenTelemetry Collector instance to receive the traces and metrics. """ # Load environment variables from .env file @@ -79,13 +84,18 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals if stream: print("Assistant: ", end="") async for chunk in client.get_response( - [Message(role="user", text=message)], stream=True, tools=get_weather + [Message(role="user", contents=[message])], + stream=True, + options={"tools": [get_weather]}, ): if chunk.text: print(chunk.text, end="") print("") else: - response = await client.get_response([Message(role="user", text=message)], tools=get_weather) + response = await client.get_response( + [Message(role="user", contents=[message])], + options={"tools": [get_weather]}, + ) print(f"Assistant: {response}") @@ -102,7 +112,7 @@ async def run_tool() -> None: with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT): print("Running scenario: AI Function") weather = await get_weather.invoke(location="Amsterdam") - print(f"Weather in Amsterdam:\n{weather}") + print(f"Weather in Amsterdam:\n{weather[-1]}") async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"): @@ -153,7 +163,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. if scenario == "tool" or scenario == "all": diff --git a/python/samples/02-agents/providers/amazon/README.md b/python/samples/02-agents/providers/amazon/README.md index ab10aeecfd..5dfd3e2c82 100644 --- a/python/samples/02-agents/providers/amazon/README.md +++ b/python/samples/02-agents/providers/amazon/README.md @@ -1,7 +1,7 @@ # Bedrock Examples This folder contains examples demonstrating how to use AWS Bedrock models with the Agent Framework. The sample -uses `BEDROCK_CHAT_MODEL_ID`, `BEDROCK_REGION`, and AWS credentials (`AWS_ACCESS_KEY_ID`, +uses `BEDROCK_CHAT_MODEL`, `BEDROCK_REGION`, and AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`). ## Examples @@ -12,6 +12,6 @@ uses `BEDROCK_CHAT_MODEL_ID`, `BEDROCK_REGION`, and AWS credentials (`AWS_ACCESS ## Environment Variables -- `BEDROCK_CHAT_MODEL_ID`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) +- `BEDROCK_CHAT_MODEL`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) - `BEDROCK_REGION`: AWS region (defaults to `us-east-1` if unset) - AWS credentials via standard variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`) diff --git a/python/samples/02-agents/providers/amazon/bedrock_chat_client.py b/python/samples/02-agents/providers/amazon/bedrock_chat_client.py index a61944bcbd..2c1f0b8845 100644 --- a/python/samples/02-agents/providers/amazon/bedrock_chat_client.py +++ b/python/samples/02-agents/providers/amazon/bedrock_chat_client.py @@ -17,7 +17,7 @@ Bedrock Chat Client Example This sample demonstrates using `BedrockChatClient` with an agent and a simple tool. Environment variables used: -- `BEDROCK_CHAT_MODEL_ID` +- `BEDROCK_CHAT_MODEL` - `BEDROCK_REGION` (defaults to `us-east-1` if unset) - AWS credentials via standard variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`) diff --git a/python/samples/02-agents/providers/anthropic/README.md b/python/samples/02-agents/providers/anthropic/README.md index 84a3b855d7..0db183490a 100644 --- a/python/samples/02-agents/providers/anthropic/README.md +++ b/python/samples/02-agents/providers/anthropic/README.md @@ -28,13 +28,14 @@ This folder contains examples demonstrating how to use Anthropic's Claude models ### Anthropic Client - `ANTHROPIC_API_KEY`: Your Anthropic API key (get one from [Anthropic Console](https://console.anthropic.com/)) -- `ANTHROPIC_CHAT_MODEL_ID`: The Claude model to use (e.g., `claude-haiku-4-5`, `claude-sonnet-4-5-20250929`) +- `ANTHROPIC_CHAT_MODEL`: The Claude model to use (e.g., `claude-haiku-4-5`, `claude-sonnet-4-5-20250929`) ### Foundry - `ANTHROPIC_FOUNDRY_API_KEY`: Your Foundry Anthropic API key -- `ANTHROPIC_FOUNDRY_ENDPOINT`: The endpoint URL for your Foundry Anthropic resource -- `ANTHROPIC_CHAT_MODEL_ID`: The Claude model to use in Foundry (e.g., `claude-haiku-4-5`) +- `ANTHROPIC_FOUNDRY_RESOURCE`: Your Foundry resource name (for example `my-foundry-resource`) +- `ANTHROPIC_FOUNDRY_BASE_URL`: Optional full Foundry Anthropic base URL alternative to `ANTHROPIC_FOUNDRY_RESOURCE` +- `ANTHROPIC_CHAT_MODEL`: The Claude model to use in Foundry (e.g., `claude-haiku-4-5`) ### Claude Agent diff --git a/python/samples/02-agents/providers/anthropic/anthropic_advanced.py b/python/samples/02-agents/providers/anthropic/anthropic_advanced.py index b9647f104e..e6d3aec002 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_advanced.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_advanced.py @@ -1,8 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework import Agent -from agent_framework.anthropic import AnthropicChatOptions, AnthropicClient +from agent_framework.anthropic import AnthropicClient from dotenv import load_dotenv # Load environment variables from .env file @@ -15,12 +16,20 @@ This sample demonstrates using Anthropic with: - Setting up an Anthropic-based agent with hosted tools. - Using the `thinking` feature. - Displaying both thinking and usage information during streaming responses. + +Environment variables: + ANTHROPIC_API_KEY — Your Anthropic API key + ANTHROPIC_CHAT_MODEL — The Anthropic model to use (e.g., "claude-sonnet-4-6") + """ async def main() -> None: """Example of streaming response (get results as they are generated).""" - client = AnthropicClient[AnthropicChatOptions]() + client = AnthropicClient( + api_key=os.getenv("ANTHROPIC_API_KEY"), + model=os.getenv("ANTHROPIC_CHAT_MODEL"), + ) # Create MCP tool configuration using instance method mcp_tool = client.get_mcp_tool( diff --git a/python/samples/02-agents/providers/anthropic/anthropic_basic.py b/python/samples/02-agents/providers/anthropic/anthropic_basic.py index 5c62aa82c8..3ff9f81504 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_basic.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_basic.py @@ -35,7 +35,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming Response Example ===") agent = Agent( - client=AnthropicClient(), + client=AnthropicClient(model="claude-sonnet-4-5-20250929"), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, @@ -52,7 +52,7 @@ async def streaming_example() -> None: print("=== Streaming Response Example ===") agent = Agent( - client=AnthropicClient(), + client=AnthropicClient(model="claude-sonnet-4-5-20250929"), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, diff --git a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_mcp.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_mcp.py index 991481487c..67f702860f 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_mcp.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_mcp.py @@ -12,6 +12,9 @@ Supported MCP server types: - "http": Remote HTTP server - "sse": Remote SSE (Server-Sent Events) server +Environment variables: +- ANTHROPIC_API_KEY: Your Anthropic API key + SECURITY NOTE: MCP servers can expose powerful capabilities. Only configure servers you trust. Use permission handlers to control what actions are allowed. """ diff --git a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_multiple_permissions.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_multiple_permissions.py index e4165089c0..12181b39eb 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_multiple_permissions.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_multiple_permissions.py @@ -15,6 +15,9 @@ Available built-in tools: - "Glob": Search for files by pattern - "Grep": Search file contents +Environment variables: +- ANTHROPIC_API_KEY: Your Anthropic API key + SECURITY NOTE: Only enable permissions that are necessary for your use case. More permissions mean more potential for unintended actions. """ @@ -24,6 +27,10 @@ from typing import Any from agent_framework.anthropic import ClaudeAgent from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() async def prompt_permission( diff --git a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_session.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_session.py index 8a022624b5..31bd1457d7 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_session.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_session.py @@ -6,6 +6,9 @@ Claude Agent with Session Management This sample demonstrates session management with ClaudeAgent, showing persistent conversation capabilities. Sessions are automatically persisted by the Claude Code CLI. + +Environment variables: +- ANTHROPIC_API_KEY: Your Anthropic API key """ import asyncio @@ -14,8 +17,12 @@ from typing import Annotated from agent_framework import tool from agent_framework.anthropic import ClaudeAgent +from dotenv import load_dotenv from pydantic import Field +# Load environment variables from .env file +load_dotenv() + @tool def get_weather( diff --git a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_shell.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_shell.py index eb8c0961fa..08562d7f1b 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_shell.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_shell.py @@ -7,6 +7,9 @@ This sample demonstrates how to enable shell command execution with ClaudeAgent. By providing a permission handler via `can_use_tool`, the agent can execute shell commands to perform tasks like listing files, running scripts, or executing system commands. +Environment variables: +- ANTHROPIC_API_KEY: Your Anthropic API key + SECURITY NOTE: Only enable shell permissions when you trust the agent's actions. Shell commands have full access to your system within the permissions of the running process. """ @@ -16,6 +19,10 @@ from typing import Any from agent_framework.anthropic import ClaudeAgent from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() async def prompt_permission( diff --git a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_tools.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_tools.py index 8ce13ef54e..29a99e013e 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_tools.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_tools.py @@ -13,11 +13,18 @@ Available built-in tools: - "Edit": Edit existing files - "Glob": Search for files by pattern - "Grep": Search file contents + +Environment variables: +- ANTHROPIC_API_KEY: Your Anthropic API key """ import asyncio from agent_framework.anthropic import ClaudeAgent +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() async def main() -> None: diff --git a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_url.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_url.py index bcb6b8b03e..5d206dd244 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_claude_with_url.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_url.py @@ -10,6 +10,9 @@ Available web tools: - "WebFetch": Fetch content from URLs - "WebSearch": Search the web +Environment variables: +- ANTHROPIC_API_KEY: Your Anthropic API key + SECURITY NOTE: Only enable URL permissions when you trust the agent's actions. URL fetching allows the agent to access any URL accessible from your network. """ @@ -17,6 +20,10 @@ URL fetching allows the agent to access any URL accessible from your network. import asyncio from agent_framework.anthropic import ClaudeAgent +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() async def main() -> None: diff --git a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py index fdbe59d90f..8807059362 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py @@ -2,8 +2,7 @@ import asyncio from agent_framework import Agent -from agent_framework.anthropic import AnthropicClient -from anthropic import AsyncAnthropicFoundry +from agent_framework.foundry import AnthropicFoundryClient from dotenv import load_dotenv # Load environment variables from .env file @@ -22,16 +21,19 @@ This example requires `anthropic>=0.74.0` and an endpoint in Foundry for Anthrop To use the Foundry integration ensure you have the following environment variables set: - ANTHROPIC_FOUNDRY_API_KEY Alternatively you can pass in a azure_ad_token_provider function to the AsyncAnthropicFoundry constructor. -- ANTHROPIC_FOUNDRY_ENDPOINT - Should be something like https://.services.ai.azure.com/anthropic/ -- ANTHROPIC_CHAT_MODEL_ID +- ANTHROPIC_FOUNDRY_RESOURCE + Should be the resource name portion of your Foundry Anthropic URL, such as . +- ANTHROPIC_FOUNDRY_BASE_URL + Optional alternative to ANTHROPIC_FOUNDRY_RESOURCE. Should be something like + https://.services.ai.azure.com/anthropic/ +- ANTHROPIC_CHAT_MODEL Should be something like claude-haiku-4-5 """ async def main() -> None: """Example of streaming response (get results as they are generated).""" - client = AnthropicClient(anthropic_client=AsyncAnthropicFoundry()) + client = AnthropicFoundryClient() # Create MCP tool configuration using instance method mcp_tool = client.get_mcp_tool( diff --git a/python/samples/02-agents/providers/anthropic/anthropic_skills.py b/python/samples/02-agents/providers/anthropic/anthropic_skills.py index a93874c269..5f4d1d40b9 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_skills.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_skills.py @@ -21,6 +21,10 @@ This sample demonstrates using Anthropic with: You can also set additonal_chat_options with "additional_beta_flags" per request. - Creating an agent with the Code Interpreter tool and a Skill. - Catching and downloading generated files from the agent. + +Environment variables: +- ANTHROPIC_API_KEY: Your Anthropic API key +- ANTHROPIC_CHAT_MODEL_ID: The Anthropic model to use, such as "claude-sonnet-4-5-20250929" """ @@ -29,7 +33,7 @@ async def main() -> None: client = AnthropicClient[AnthropicChatOptions](additional_beta_flags=["skills-2025-10-02"]) # List Anthropic-managed Skills - skills = await client.anthropic_client.beta.skills.list(source="anthropic", betas=["skills-2025-10-02"]) + skills = await client.anthropic_client.beta.skills.list(source="anthropic", betas=["skills-2025-10-02"]) # type: ignore for skill in skills.data: print(f"{skill.source}: {skill.id} (version: {skill.latest_version})") @@ -81,7 +85,7 @@ async def main() -> None: # Since I'm using the pptx skill, the files will be PowerPoint presentations print("Generated files:") for idx, file in enumerate(files): - file_content = await client.anthropic_client.beta.files.download( + file_content = await client.anthropic_client.beta.files.download( # type: ignore file_id=file.file_id, betas=["files-api-2025-04-14"] ) with open(Path(__file__).parent / f"python_programming-{idx}.pptx", "wb") as f: diff --git a/python/samples/02-agents/providers/azure/README.md b/python/samples/02-agents/providers/azure/README.md index cd34fe2717..ab33281a37 100644 --- a/python/samples/02-agents/providers/azure/README.md +++ b/python/samples/02-agents/providers/azure/README.md @@ -26,7 +26,7 @@ This folder contains Azure-backed samples for the generic OpenAI clients in Set these before running the Azure provider samples: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` Optionally, you can also set: diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py index 030828da89..9381d7f80c 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: agent = Agent( client=OpenAIChatCompletionClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), @@ -60,7 +60,7 @@ async def streaming_example() -> None: agent = Agent( client=OpenAIChatCompletionClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py index cf26f6ba38..afe73b428d 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py @@ -41,7 +41,7 @@ async def main() -> None: # authentication option. agent = Agent( client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=AzureCliCredential(), ), diff --git a/python/samples/02-agents/providers/azure/openai_client_basic.py b/python/samples/02-agents/providers/azure/openai_client_basic.py index 3029c03cda..0bb061ef92 100644 --- a/python/samples/02-agents/providers/azure/openai_client_basic.py +++ b/python/samples/02-agents/providers/azure/openai_client_basic.py @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: agent = Agent( client=OpenAIChatClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), @@ -60,7 +60,7 @@ async def streaming_example() -> None: agent = Agent( client=OpenAIChatClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), diff --git a/python/samples/02-agents/providers/azure/openai_client_with_session.py b/python/samples/02-agents/providers/azure/openai_client_with_session.py index ad1c87f2d2..8a1fe43f1e 100644 --- a/python/samples/02-agents/providers/azure/openai_client_with_session.py +++ b/python/samples/02-agents/providers/azure/openai_client_with_session.py @@ -76,19 +76,19 @@ async def example_with_session_persistence_in_memory() -> None: # First conversation query1 = "What's the weather like in Tokyo?" print(f"User: {query1}") - result1 = await agent.run(query1, session=session, store=False) + result1 = await agent.run(query1, session=session, options={"store": False}) print(f"Agent: {result1.text}") # Second conversation using the same session - maintains context query2 = "How about London?" print(f"\nUser: {query2}") - result2 = await agent.run(query2, session=session, store=False) + result2 = await agent.run(query2, session=session, options={"store": False}) print(f"Agent: {result2.text}") # Third conversation - agent should remember both previous cities query3 = "Which of the cities I asked about has better weather?" print(f"\nUser: {query3}") - result3 = await agent.run(query3, session=session, store=False) + result3 = await agent.run(query3, session=session, options={"store": False}) print(f"Agent: {result3.text}") print("Note: The agent remembers context from previous messages in the same session.\n") @@ -114,7 +114,7 @@ async def example_with_existing_session_id() -> None: query1 = "What's the weather in Paris?" print(f"User: {query1}") - result1 = await agent.run(query1, session=session) + result1 = await agent.run(query1, session=session, options={"store": False}) print(f"Agent: {result1.text}") # The session ID is set after the first response diff --git a/python/samples/02-agents/providers/custom/README.md b/python/samples/02-agents/providers/custom/README.md index 766e5e0269..976edd29a0 100644 --- a/python/samples/02-agents/providers/custom/README.md +++ b/python/samples/02-agents/providers/custom/README.md @@ -27,7 +27,7 @@ Both approaches allow you to extend the framework for your specific use cases wh ## Understanding Raw Client Classes -The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIChatCompletionClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. +The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIChatCompletionClient`, `RawFoundryChatClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. ### Warning: Raw Clients Should Not Normally Be Used Directly @@ -62,8 +62,8 @@ For most use cases, use the fully-featured public client classes which already h - `OpenAIChatCompletionClient` - OpenAI Chat Completions API with all layers - `OpenAIChatClient` - OpenAI Responses API with all layers -- `AzureOpenAIChatClient` - Azure OpenAI Chat with all layers -- `AzureOpenAIResponsesClient` - Azure OpenAI Responses with all layers -- `AzureAIClient` - Azure AI Project with all layers +- `OpenAIChatCompletionClient` - Azure OpenAI Chat Completions with all layers +- `OpenAIChatClient` - Azure OpenAI Responses with all layers +- `FoundryChatClient` - Azure AI Foundry project-backed chat with all layers These clients handle the layer composition correctly and provide the full feature set out of the box. diff --git a/python/samples/02-agents/providers/custom/custom_agent.py b/python/samples/02-agents/providers/custom/custom_agent.py index 595e6b6d9d..1957f2e086 100644 --- a/python/samples/02-agents/providers/custom/custom_agent.py +++ b/python/samples/02-agents/providers/custom/custom_agent.py @@ -51,9 +51,9 @@ class EchoAgent(BaseAgent): super().__init__( name=name, description=description, - echo_prefix=echo_prefix, # type: ignore **kwargs, ) + self.echo_prefix = echo_prefix def run( self, diff --git a/python/samples/02-agents/providers/foundry/README.md b/python/samples/02-agents/providers/foundry/README.md index 80ddab2dcd..2025b0f4fe 100644 --- a/python/samples/02-agents/providers/foundry/README.md +++ b/python/samples/02-agents/providers/foundry/README.md @@ -9,7 +9,6 @@ This folder contains Azure AI Foundry and Foundry Local samples for Agent Framew | [`foundry_agent_basic.py`](foundry_agent_basic.py) | Foundry Agent basic example | | [`foundry_agent_custom_client.py`](foundry_agent_custom_client.py) | Foundry Agent custom client configuration | | [`foundry_agent_hosted.py`](foundry_agent_hosted.py) | Foundry Agent for hosted agents | -| [`foundry_agent_with_env_vars.py`](foundry_agent_with_env_vars.py) | Foundry Agent using environment variables | | [`foundry_agent_with_function_tools.py`](foundry_agent_with_function_tools.py) | Foundry Agent with local function tools | ## FoundryChatClient Samples @@ -45,4 +44,4 @@ This folder contains Azure AI Foundry and Foundry Local samples for Agent Framew ### Environment Variables -- `FOUNDRY_LOCAL_MODEL_ID`: Optional model alias/ID to use by default when `model_id` is not passed to `FoundryLocalClient`. +- `FOUNDRY_LOCAL_MODEL`: Optional model alias/ID to use by default when `model` is not passed to `FoundryLocalClient`. diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_custom_client.py b/python/samples/02-agents/providers/foundry/foundry_agent_custom_client.py index 6ab3e44ee2..8db5b711ab 100644 --- a/python/samples/02-agents/providers/foundry/foundry_agent_custom_client.py +++ b/python/samples/02-agents/providers/foundry/foundry_agent_custom_client.py @@ -1,11 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. - import asyncio +import os from agent_framework import Agent from agent_framework.foundry import FoundryAgent, RawFoundryAgentChatClient from azure.identity import AzureCliCredential +from dotenv import load_dotenv +load_dotenv() """ Foundry Agent — Custom client configuration @@ -25,9 +27,9 @@ Environment variables: async def main() -> None: # Option 1: Default — full middleware on both agent and client agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-agent", - agent_version="1.0", + project_endpoint=os.getenv("FOUNDRY_PROJECT_ENDPOINT"), + agent_name=os.getenv("FOUNDRY_AGENT_NAME"), + agent_version=os.getenv("FOUNDRY_AGENT_VERSION"), credential=AzureCliCredential(), ) result = await agent.run("Hello from the default setup!") @@ -35,9 +37,9 @@ async def main() -> None: # Option 2: Raw client — no client-level middleware (agent middleware still active) agent_raw_client = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-agent", - agent_version="1.0", + project_endpoint=os.getenv("FOUNDRY_PROJECT_ENDPOINT"), + agent_name=os.getenv("FOUNDRY_AGENT_NAME"), + agent_version=os.getenv("FOUNDRY_AGENT_VERSION"), credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient, ) @@ -47,9 +49,9 @@ async def main() -> None: # Option 3: Composition — use Agent(client=...) directly # this will not run the checks that the `FoundryAgent` does on things like tools. client = RawFoundryAgentChatClient( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-agent", - agent_version="1.0", + project_endpoint=os.getenv("FOUNDRY_PROJECT_ENDPOINT"), + agent_name=os.getenv("FOUNDRY_AGENT_NAME"), + agent_version=os.getenv("FOUNDRY_AGENT_VERSION"), credential=AzureCliCredential(), ) agent_composed = Agent(client=client) diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_hosted.py b/python/samples/02-agents/providers/foundry/foundry_agent_hosted.py index 188e374af9..1e9dbc6510 100644 --- a/python/samples/02-agents/providers/foundry/foundry_agent_hosted.py +++ b/python/samples/02-agents/providers/foundry/foundry_agent_hosted.py @@ -1,9 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework.foundry import FoundryAgent from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() """ Foundry Agent — Connect to a HostedAgent (no version needed) @@ -20,8 +24,8 @@ Environment variables: async def main() -> None: # HostedAgents don't need agent_version agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-hosted-agent", + project_endpoint=os.getenv("FOUNDRY_PROJECT_ENDPOINT"), + agent_name=os.getenv("FOUNDRY_AGENT_NAME"), credential=AzureCliCredential(), ) diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_with_env_vars.py b/python/samples/02-agents/providers/foundry/foundry_agent_with_env_vars.py deleted file mode 100644 index 4b64025de4..0000000000 --- a/python/samples/02-agents/providers/foundry/foundry_agent_with_env_vars.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework.foundry import FoundryAgent -from azure.identity import AzureCliCredential - -""" -Foundry Agent with Environment Variables - -This sample shows the recommended pattern for advanced samples that use -environment variables for configuration. - -Environment variables: - FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint - FOUNDRY_AGENT_NAME — Name of the agent in Foundry - FOUNDRY_AGENT_VERSION — Version of the agent (optional, for PromptAgents) -""" - - -async def main() -> None: - agent = FoundryAgent( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - agent_name=os.environ["FOUNDRY_AGENT_NAME"], - agent_version=os.environ.get("FOUNDRY_AGENT_VERSION"), - credential=AzureCliCredential(), - ) - - session = agent.create_session() - - result = await agent.run("Hello! My name is Alice.", session=session) - print(f"Agent: {result}\n") - - result = await agent.run("What's my name?", session=session) - print(f"Agent: {result}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_with_function_tools.py b/python/samples/02-agents/providers/foundry/foundry_agent_with_function_tools.py index 29277d563f..2915cee1b6 100644 --- a/python/samples/02-agents/providers/foundry/foundry_agent_with_function_tools.py +++ b/python/samples/02-agents/providers/foundry/foundry_agent_with_function_tools.py @@ -1,13 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from typing import Annotated -from agent_framework import tool from agent_framework.foundry import FoundryAgent from azure.identity import AzureCliCredential -from pydantic import Field +from dotenv import load_dotenv +load_dotenv() """ Foundry Agent with Local Function Tools @@ -25,9 +26,8 @@ Environment variables: """ -@tool(approval_mode="never_require") def get_weather( - location: Annotated[str, Field(description="The city to get weather for.")], + location: Annotated[str, "The city to get weather for."], ) -> str: """Get the current weather for a location.""" return f"The weather in {location} is sunny, 22°C." @@ -35,11 +35,11 @@ def get_weather( async def main() -> None: agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-weather-agent", - agent_version="1.0", + project_endpoint=os.getenv("FOUNDRY_PROJECT_ENDPOINT"), + agent_name=os.getenv("FOUNDRY_AGENT_NAME"), + agent_version=os.getenv("FOUNDRY_AGENT_VERSION"), credential=AzureCliCredential(), - tools=[get_weather], + tools=get_weather, ) result = await agent.run("What's the weather in Paris?") diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_code_interpreter_files.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_code_interpreter_files.py index 1e156da17c..6fff2c3069 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_code_interpreter_files.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_code_interpreter_files.py @@ -8,9 +8,8 @@ from agent_framework import Agent from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv -from openai import AsyncAzureOpenAI +from openai import AsyncOpenAI -# Load environment variables from .env file load_dotenv() """ @@ -18,12 +17,16 @@ Foundry Chat Client with Code Interpreter and Files Example This sample demonstrates using get_code_interpreter_tool() with Responses on Foundry for Python code execution and data analysis with uploaded files. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Foundry project endpoint + FOUNDRY_MODEL — Foundry model to use (e.g. "gpt-4o-mini") """ # Helper functions -async def create_sample_file_and_upload(openai_client: AsyncAzureOpenAI) -> tuple[str, str]: +async def create_sample_file_and_upload(openai_client: AsyncOpenAI) -> tuple[str, str]: """Create a sample CSV file and upload it for Foundry code interpreter use.""" csv_data = """name,department,salary,years_experience Alice Johnson,Engineering,95000,5 @@ -51,7 +54,7 @@ Frank Wilson,Engineering,88000,6 return temp_file_path, uploaded_file.id -async def cleanup_files(openai_client: AsyncAzureOpenAI, temp_file_path: str, file_id: str) -> None: +async def cleanup_files(openai_client: AsyncOpenAI, temp_file_path: str, file_id: str) -> None: """Clean up both local temporary file and uploaded file.""" # Clean up: delete the uploaded file await openai_client.files.delete(file_id) @@ -65,39 +68,29 @@ async def cleanup_files(openai_client: AsyncAzureOpenAI, temp_file_path: str, fi async def main() -> None: print("=== Foundry Chat Client with Code Interpreter and File Upload ===") - # Initialize the underlying OpenAI client for file operations - credential = AzureCliCredential() - - async def get_token(): - token = credential.get_token("https://cognitiveservices.azure.com/.default") - return token.token - - openai_client = AsyncAzureOpenAI( - azure_ad_token_provider=get_token, - api_version="2024-05-01-preview", + # Create the FoundryChatClient + client = FoundryChatClient( + project_endpoint=os.getenv("FOUNDRY_PROJECT_ENDPOINT"), + model=os.getenv("FOUNDRY_MODEL"), + credential=AzureCliCredential(), ) - + # use the openai client from the foundry client to upload files for the code interpreter tool + openai_client = client.project_client.get_openai_client() temp_file_path, file_id = await create_sample_file_and_upload(openai_client) - - # Create agent using FoundryChatClient - client = FoundryChatClient(credential=credential) - - # Create code interpreter tool with file access - code_interpreter_tool = client.get_code_interpreter_tool(file_ids=[file_id]) - + # Create agent with code interpreter tool with file access agent = Agent( client=client, instructions="You are a helpful assistant that can analyze data files using Python code.", - tools=[code_interpreter_tool], + tools=FoundryChatClient.get_code_interpreter_tool(file_ids=[file_id]), ) - - # Test the code interpreter with the uploaded file - query = "Analyze the employee data in the uploaded CSV file. Calculate average salary by department." - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result.text}") - - await cleanup_files(openai_client, temp_file_path, file_id) + try: + # Test the code interpreter with the uploaded file + query = "Analyze the employee data in the uploaded CSV file. Calculate average salary by department." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}") + finally: + await cleanup_files(openai_client, temp_file_path, file_id) if __name__ == "__main__": diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py index 214ff005e9..3089fd1856 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py @@ -51,7 +51,7 @@ async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", s """Here we let the session deal with the previous responses, and we just rerun with the approval.""" from agent_framework import Message - result = await agent.run(query, session=session, store=True) + result = await agent.run(query, session=session, options={"store": True}) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -66,7 +66,7 @@ async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", s contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, session=session, store=True) + result = await agent.run(new_input, session=session, options={"store": True}) return result diff --git a/python/samples/02-agents/providers/ollama/README.md b/python/samples/02-agents/providers/ollama/README.md index 2a10ae2f57..2dca35a8fd 100644 --- a/python/samples/02-agents/providers/ollama/README.md +++ b/python/samples/02-agents/providers/ollama/README.md @@ -40,8 +40,8 @@ Set the following environment variables: - `OLLAMA_HOST`: The base URL for your Ollama server (optional, defaults to `http://localhost:11434`) - Example: `export OLLAMA_HOST="http://localhost:11434"` -- `OLLAMA_MODEL_ID`: The model name to use - - Example: `export OLLAMA_MODEL_ID="qwen2.5:8b"` +- `OLLAMA_MODEL`: The model name to use + - Example: `export OLLAMA_MODEL="qwen2.5:8b"` - Must be a model you have pulled with Ollama ### For OpenAI Client with Ollama (`ollama_with_openai_chat_client.py`) diff --git a/python/samples/02-agents/providers/ollama/ollama_agent_basic.py b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py index 3652b069c4..a987c05398 100644 --- a/python/samples/02-agents/providers/ollama/ollama_agent_basic.py +++ b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py @@ -17,7 +17,7 @@ This sample demonstrates implementing a Ollama agent with basic tool usage. Ensure to install Ollama and have a model running locally before running the sample Not all Models support function calling, to test function calling try llama3.2 or qwen3:4b -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ diff --git a/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py b/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py index b23727311b..24defc5a8f 100644 --- a/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py +++ b/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py @@ -16,7 +16,7 @@ This sample demonstrates implementing a Ollama agent with reasoning. Ensure to install Ollama and have a model running locally before running the sample Not all Models support reasoning, to test reasoning try qwen3:8b -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ diff --git a/python/samples/02-agents/providers/ollama/ollama_chat_client.py b/python/samples/02-agents/providers/ollama/ollama_chat_client.py index c92eaa0f55..adf2d3818e 100644 --- a/python/samples/02-agents/providers/ollama/ollama_chat_client.py +++ b/python/samples/02-agents/providers/ollama/ollama_chat_client.py @@ -17,7 +17,7 @@ This sample demonstrates using the native Ollama Chat Client directly. Ensure to install Ollama and have a model running locally before running the sample. Not all Models support function calling, to test function calling try llama3.2 -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ @@ -35,7 +35,7 @@ def get_time(): async def main() -> None: client = OllamaChatClient() message = "What time is it? Use a tool call" - messages = [Message(role="user", text=message)] + messages = [Message(role="user", contents=[message])] stream = False print(f"User: {message}") if stream: diff --git a/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py b/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py index bfeb7824fa..41e7c3aedd 100644 --- a/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py +++ b/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py @@ -16,7 +16,7 @@ This sample demonstrates implementing a Ollama agent with multimodal input capab Ensure to install Ollama and have a model running locally before running the sample Not all Models support multimodal input, to test multimodal input try gemma3:4b -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ diff --git a/python/samples/02-agents/providers/openai/client_reasoning.py b/python/samples/02-agents/providers/openai/client_reasoning.py index 2eea8d2106..7f55f3ee3d 100644 --- a/python/samples/02-agents/providers/openai/client_reasoning.py +++ b/python/samples/02-agents/providers/openai/client_reasoning.py @@ -25,7 +25,7 @@ In this case they are here: https://platform.openai.com/docs/api-reference/respo agent = Agent( - client=OpenAIChatClient[OpenAIChatOptions](model_id="gpt-5"), + client=OpenAIChatClient[OpenAIChatOptions](model="gpt-5"), name="MathHelper", instructions="You are a personal math tutor. When asked a math question, " "reason over how best to approach the problem and share your thought process.", diff --git a/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py index ffcdadb8da..f9cc0c7148 100644 --- a/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py @@ -50,7 +50,7 @@ async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", s """Here we let the session deal with the previous responses, and we just rerun with the approval.""" from agent_framework import Message - result = await agent.run(query, session=session, store=True) + result = await agent.run(query, session=session, options={"store": True}) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -65,7 +65,7 @@ async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", s contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, session=session, store=True) + result = await agent.run(new_input, session=session, options={"store": True}) return result diff --git a/python/samples/02-agents/providers/openai/client_with_local_shell.py b/python/samples/02-agents/providers/openai/client_with_local_shell.py index f4829cc5e7..1de710c9d4 100644 --- a/python/samples/02-agents/providers/openai/client_with_local_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_local_shell.py @@ -18,6 +18,9 @@ This sample demonstrates implementing a local shell tool using get_shell_tool(fu that wraps Python's subprocess module. Unlike the hosted shell tool (get_shell_tool()), local shell execution runs commands on YOUR machine, not in a remote container. +Currently not all models support the shell tool. Refer to the OpenAI documentation for the +list of supported models: https://developers.openai.com/api/docs/models/ + SECURITY NOTE: This example executes real commands on your local machine. Only enable this when you trust the agent's actions. Consider implementing allowlists, sandboxing, or approval workflows for production use. @@ -53,7 +56,10 @@ async def main() -> None: print("=== OpenAI Agent with Local Shell Tool Example ===") print("NOTE: Commands will execute on your local machine.\n") - client = OpenAIChatClient() + # Currently not all models support the shell tool. Refer to the OpenAI + # documentation for the list of supported models: + # https://developers.openai.com/api/docs/models/ + client = OpenAIChatClient(model="gpt-5.4-nano") local_shell_tool = client.get_shell_tool( func=run_bash, ) diff --git a/python/samples/02-agents/providers/openai/client_with_session.py b/python/samples/02-agents/providers/openai/client_with_session.py index 0dffeaef6c..f9e398ca99 100644 --- a/python/samples/02-agents/providers/openai/client_with_session.py +++ b/python/samples/02-agents/providers/openai/client_with_session.py @@ -75,19 +75,19 @@ async def example_with_session_persistence_in_memory() -> None: # First conversation query1 = "What's the weather like in Tokyo?" print(f"User: {query1}") - result1 = await agent.run(query1, session=session, store=False) + result1 = await agent.run(query1, session=session, options={"store": False}) print(f"Agent: {result1.text}") # Second conversation using the same session - maintains context query2 = "How about London?" print(f"\nUser: {query2}") - result2 = await agent.run(query2, session=session, store=False) + result2 = await agent.run(query2, session=session, options={"store": False}) print(f"Agent: {result2.text}") # Third conversation - agent should remember both previous cities query3 = "Which of the cities I asked about has better weather?" print(f"\nUser: {query3}") - result3 = await agent.run(query3, session=session, store=False) + result3 = await agent.run(query3, session=session, options={"store": False}) print(f"Agent: {result3.text}") print("Note: The agent remembers context from previous messages in the same session.\n") diff --git a/python/samples/02-agents/providers/openai/client_with_shell.py b/python/samples/02-agents/providers/openai/client_with_shell.py index 5043d8e4a1..6a501dea81 100644 --- a/python/samples/02-agents/providers/openai/client_with_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_shell.py @@ -17,6 +17,9 @@ for executing shell commands in a managed container environment hosted by OpenAI The shell tool allows the model to run commands like listing files, running scripts, or performing system operations within a secure, sandboxed container. + +Currently not all models support the shell tool. Refer to the OpenAI documentation +for the list of supported models: https://developers.openai.com/api/docs/models/ """ @@ -24,7 +27,10 @@ async def main() -> None: """Example showing how to use the shell tool with OpenAI Chat.""" print("=== OpenAI Chat Client Agent with Shell Tool Example ===") - client = OpenAIChatClient() + # Currently not all models support the shell tool. Refer to the OpenAI + # documentation for the list of supported models: + # https://developers.openai.com/api/docs/models/ + client = OpenAIChatClient(model="gpt-5.4-nano") # Create a hosted shell tool with the default auto container environment shell_tool = client.get_shell_tool() diff --git a/python/samples/02-agents/response_stream.py b/python/samples/02-agents/response_stream.py index 840dc18b26..ad6d3df3ba 100644 --- a/python/samples/02-agents/response_stream.py +++ b/python/samples/02-agents/response_stream.py @@ -256,7 +256,7 @@ async def main() -> None: """Result hook that wraps the response text in quotes.""" if response.text: return ChatResponse( - messages=[Message(text=f'"{response.text}"', role="assistant")], + messages=[Message(contents=[f'"{response.text}"'], role="assistant")], additional_properties=response.additional_properties, ) return response @@ -293,7 +293,7 @@ async def main() -> None: # In real code, this would create an AgentResponse text = "".join(u.text or "" for u in updates) return ChatResponse( - messages=[Message(text=f"[AGENT FINAL] {text}", role="assistant")], + messages=[Message(contents=[f"[AGENT FINAL] {text}"], role="assistant")], additional_properties={"layer": "agent"}, ) diff --git a/python/samples/02-agents/skills/README.md b/python/samples/02-agents/skills/README.md index 29f6a85e31..b401278577 100644 --- a/python/samples/02-agents/skills/README.md +++ b/python/samples/02-agents/skills/README.md @@ -53,3 +53,9 @@ All samples require: - An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`) - Azure CLI authentication (`az login`) - Environment variables set in a `.env` file (see `python/.env.example`) + +## Suppressing the experimental warning + +The Agent Skills APIs in these samples are still experimental. Each sample includes +a short commented `warnings.filterwarnings(...)` snippet near the imports. Uncomment +it if you want to suppress the Skills warning before using the experimental APIs. diff --git a/python/samples/02-agents/skills/code_defined_skill/README.md b/python/samples/02-agents/skills/code_defined_skill/README.md index ae70268ca4..c19a3c17d3 100644 --- a/python/samples/02-agents/skills/code_defined_skill/README.md +++ b/python/samples/02-agents/skills/code_defined_skill/README.md @@ -27,8 +27,8 @@ code_defined_skill/ Set the required environment variables in a `.env` file (see `python/.env.example`): -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `AZURE_OPENAI_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py index 0d8da5e6d4..a68b09540b 100644 --- a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py +++ b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py @@ -3,6 +3,11 @@ import asyncio import json import os + +# Uncomment this filter to suppress the experimental Skills warning before +# using the sample's Skills APIs. +# import warnings # isort: skip +# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from textwrap import dedent from typing import Any @@ -89,7 +94,7 @@ def conversion_policy(**kwargs: Any) -> Any: Args: **kwargs: Runtime keyword arguments from ``agent.run()``. - For example, ``agent.run(..., precision=2)`` + For example, ``agent.run(..., function_invocation_kwargs={"precision": 2})`` makes ``kwargs["precision"]`` available here. """ precision = kwargs.get("precision", 4) @@ -137,21 +142,17 @@ async def main() -> None: credential=AzureCliCredential(), ) - # Create the skills provider with the code-defined skill - skills_provider = SkillsProvider( - skills=[unit_converter_skill], - ) - + # Create the skills provider with the code-defined skill and pass it to the agent async with Agent( client=client, instructions="You are a helpful assistant that can convert units.", - context_providers=[skills_provider], + context_providers=[SkillsProvider(skills=[unit_converter_skill])], ) as agent: print("Converting units") print("-" * 60) response = await agent.run( "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?", - precision=2, + function_invocation_kwargs={"precision": 2}, ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/file_based_skill/README.md b/python/samples/02-agents/skills/file_based_skill/README.md index ebc686941f..fa07b7e973 100644 --- a/python/samples/02-agents/skills/file_based_skill/README.md +++ b/python/samples/02-agents/skills/file_based_skill/README.md @@ -47,8 +47,8 @@ file_based_skill/ Set the required environment variables in a `.env` file (see `python/.env.example`): -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `AZURE_OPENAI_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py index bcb6bc35ff..809c1cdd63 100644 --- a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py +++ b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py @@ -3,6 +3,11 @@ import asyncio import os import sys + +# Uncomment this filter to suppress the experimental Skills warning before +# using the sample's Skills APIs. +# import warnings +# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from pathlib import Path from agent_framework import Agent, SkillsProvider diff --git a/python/samples/02-agents/skills/mixed_skills/README.md b/python/samples/02-agents/skills/mixed_skills/README.md index 33b6760719..d41f7dbaa9 100644 --- a/python/samples/02-agents/skills/mixed_skills/README.md +++ b/python/samples/02-agents/skills/mixed_skills/README.md @@ -60,8 +60,8 @@ File scripts are executed as **local Python subprocesses** via the Set environment variables (or create a `.env` file): ``` -AZURE_AI_PROJECT_ENDPOINT=https://your-project.openai.azure.com/ -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o-mini +FOUNDRY_PROJECT_ENDPOINT=https://your-project.openai.azure.com/ +AZURE_OPENAI_MODEL=gpt-4o-mini ``` Authenticate with Azure CLI: diff --git a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py index 14d015f125..5843dcdd67 100644 --- a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py +++ b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py @@ -4,6 +4,11 @@ import asyncio import json import os import sys + +# Uncomment this filter to suppress the experimental Skills warning before +# using the sample's Skills APIs. +# import warnings +# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from pathlib import Path from textwrap import dedent from typing import Any diff --git a/python/samples/02-agents/skills/script_approval/README.md b/python/samples/02-agents/skills/script_approval/README.md index 5392e3f2ae..07dd37cb65 100644 --- a/python/samples/02-agents/skills/script_approval/README.md +++ b/python/samples/02-agents/skills/script_approval/README.md @@ -28,8 +28,8 @@ When `require_script_approval=True` is set, the agent pauses before executing an Set the required environment variables in a `.env` file (see `python/.env.example`): -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `AZURE_OPENAI_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/skills/script_approval/script_approval.py b/python/samples/02-agents/skills/script_approval/script_approval.py index 25c6c07baf..770e003d39 100644 --- a/python/samples/02-agents/skills/script_approval/script_approval.py +++ b/python/samples/02-agents/skills/script_approval/script_approval.py @@ -2,6 +2,11 @@ import asyncio import os + +# Uncomment this filter to suppress the experimental Skills warning before +# using the sample's Skills APIs. +# import warnings +# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from textwrap import dedent from agent_framework import Agent, Skill, SkillsProvider diff --git a/python/samples/02-agents/skills/subprocess_script_runner.py b/python/samples/02-agents/skills/subprocess_script_runner.py index e24724a0e0..52bac380e8 100644 --- a/python/samples/02-agents/skills/subprocess_script_runner.py +++ b/python/samples/02-agents/skills/subprocess_script_runner.py @@ -9,6 +9,11 @@ from __future__ import annotations import subprocess import sys + +# Uncomment this filter to suppress the experimental Skills warning before +# using the sample's Skills APIs. +# import warnings +# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from pathlib import Path from typing import Any diff --git a/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py b/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py index 211c3b5681..9bdf19111f 100644 --- a/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py +++ b/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Awaitable, Callable from agent_framework import Agent, AgentContext, AgentSession, FunctionInvocationContext, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv load_dotenv() @@ -63,7 +63,7 @@ def recall_findings(ctx: FunctionInvocationContext) -> str: async def main() -> None: print("=== Agent-as-Tool: Session Propagation ===\n") - client = OpenAIResponsesClient() + client = OpenAIChatClient() research_agent = Agent( client=client, diff --git a/python/samples/02-agents/tools/control_total_tool_executions.py b/python/samples/02-agents/tools/control_total_tool_executions.py index c53b228430..5b6f65a431 100644 --- a/python/samples/02-agents/tools/control_total_tool_executions.py +++ b/python/samples/02-agents/tools/control_total_tool_executions.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -81,7 +81,7 @@ async def scenario_max_iterations(): print("Scenario 1: max_iterations — limit LLM roundtrips") print("=" * 60) - client = OpenAIResponsesClient() + client = OpenAIChatClient() # 1. Set max_iterations to 3 — the tool loop will run at most 3 roundtrips # to the model before forcing a text response. @@ -116,7 +116,7 @@ async def scenario_max_function_calls(): print("Scenario 2: max_function_calls — limit total tool executions") print("=" * 60) - client = OpenAIResponsesClient() + client = OpenAIChatClient() # 1. Allow many iterations but cap total function calls to 4. # If the model requests 3 parallel searches per iteration, after 2 @@ -158,7 +158,7 @@ async def scenario_max_invocations(): print("=" * 60) agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="APIAgent", instructions="Use call_expensive_api when asked to analyze something.", tools=[call_expensive_api], @@ -214,7 +214,7 @@ async def scenario_per_agent_tool_limits(): agent_a_lookup = tool(name="lookup", approval_mode="never_require", max_invocations=2)(_do_lookup) agent_b_lookup = tool(name="lookup", approval_mode="never_require", max_invocations=5)(_do_lookup) - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent_a = Agent( client=client, name="AgentA", @@ -259,7 +259,7 @@ async def scenario_combined(): print("Scenario 5: Combined — all mechanisms together") print("=" * 60) - client = OpenAIResponsesClient() + client = OpenAIChatClient() # 1. Configure the client with both iteration and function call limits. client.function_invocation_configuration["max_iterations"] = 5 # max 5 LLM roundtrips diff --git a/python/samples/02-agents/tools/function_invocation_configuration.py b/python/samples/02-agents/tools/function_invocation_configuration.py index 78fdecbb2c..a23701bb7d 100644 --- a/python/samples/02-agents/tools/function_invocation_configuration.py +++ b/python/samples/02-agents/tools/function_invocation_configuration.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -28,7 +28,7 @@ def add( async def main(): - client = OpenAIResponsesClient() + client = OpenAIChatClient() client.function_invocation_configuration["include_detailed_errors"] = True client.function_invocation_configuration["max_iterations"] = 40 print(f"Function invocation configured as: \n{client.function_invocation_configuration}") diff --git a/python/samples/02-agents/tools/function_tool_declaration_only.py b/python/samples/02-agents/tools/function_tool_declaration_only.py index efa98d85bc..a8c4bd826e 100644 --- a/python/samples/02-agents/tools/function_tool_declaration_only.py +++ b/python/samples/02-agents/tools/function_tool_declaration_only.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Agent, FunctionTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -26,7 +26,7 @@ async def main(): ) agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DeclarationOnlyToolAgent", instructions="You are a helpful agent that uses tools.", tools=function_declaration, diff --git a/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py b/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py index 7f6b8896f0..83d2e34cf2 100644 --- a/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py +++ b/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py @@ -22,7 +22,7 @@ Usage: import asyncio from agent_framework import Agent, FunctionTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -62,7 +62,7 @@ async def main() -> None: tool = FunctionTool.from_dict(definition, dependencies={"function_tool": {"name:add_numbers": {"func": func}}}) agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="FunctionToolAgent", instructions="You are a helpful assistant.", tools=tool, diff --git a/python/samples/02-agents/tools/function_tool_recover_from_failures.py b/python/samples/02-agents/tools/function_tool_recover_from_failures.py index 48f7b80a56..001f1805b5 100644 --- a/python/samples/02-agents/tools/function_tool_recover_from_failures.py +++ b/python/samples/02-agents/tools/function_tool_recover_from_failures.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -46,7 +46,7 @@ def safe_divide( async def main(): # tools = Tools() agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[greet, safe_divide], diff --git a/python/samples/02-agents/tools/function_tool_with_approval.py b/python/samples/02-agents/tools/function_tool_with_approval.py index 3a2a565ed4..42f1da19ea 100644 --- a/python/samples/02-agents/tools/function_tool_with_approval.py +++ b/python/samples/02-agents/tools/function_tool_with_approval.py @@ -5,7 +5,7 @@ from random import randrange from typing import TYPE_CHECKING, Annotated, Any from agent_framework import Agent, AgentResponse, Message, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv if TYPE_CHECKING: @@ -134,7 +134,7 @@ async def run_weather_agent_with_approval(stream: bool) -> None: print(f"\n=== Weather Agent with Approval Required ({'Streaming' if stream else 'Non-Streaming'}) ===\n") async with Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="WeatherAgent", instructions=("You are a helpful weather assistant. Use the get_weather tool to provide weather information."), tools=[get_weather, get_weather_detail], diff --git a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py index 231a980b45..8090d4a2a0 100644 --- a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py +++ b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py @@ -18,7 +18,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import BaseModel, Field @@ -70,7 +70,7 @@ def get_current_time(timezone: str = "UTC") -> str: async def main(): agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="AssistantAgent", instructions="You are a helpful assistant. Use the available tools to answer questions.", tools=[get_weather, get_current_time], diff --git a/python/samples/02-agents/tools/function_tool_with_kwargs.py b/python/samples/02-agents/tools/function_tool_with_kwargs.py index 77664d6f8c..183ce0c999 100644 --- a/python/samples/02-agents/tools/function_tool_with_kwargs.py +++ b/python/samples/02-agents/tools/function_tool_with_kwargs.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, FunctionInvocationContext, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -44,7 +44,7 @@ def get_weather( async def main() -> None: agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather], diff --git a/python/samples/02-agents/tools/function_tool_with_max_exceptions.py b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py index b8ed2f5b58..4a40ba3cc0 100644 --- a/python/samples/02-agents/tools/function_tool_with_max_exceptions.py +++ b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -36,7 +36,7 @@ def safe_divide( async def main(): # tools = Tools() agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[safe_divide], diff --git a/python/samples/02-agents/tools/function_tool_with_max_invocations.py b/python/samples/02-agents/tools/function_tool_with_max_invocations.py index 0cea02c23e..63d33df683 100644 --- a/python/samples/02-agents/tools/function_tool_with_max_invocations.py +++ b/python/samples/02-agents/tools/function_tool_with_max_invocations.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -25,7 +25,7 @@ def unicorn_function(times: Annotated[int, "The number of unicorns to return."]) async def main(): # tools = Tools() agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[unicorn_function], diff --git a/python/samples/02-agents/tools/function_tool_with_session_injection.py b/python/samples/02-agents/tools/function_tool_with_session_injection.py index 21df5cc2c9..b1316227f1 100644 --- a/python/samples/02-agents/tools/function_tool_with_session_injection.py +++ b/python/samples/02-agents/tools/function_tool_with_session_injection.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, AgentSession, FunctionInvocationContext, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -37,7 +37,7 @@ async def get_weather( async def main() -> None: agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather], diff --git a/python/samples/02-agents/tools/tool_in_class.py b/python/samples/02-agents/tools/tool_in_class.py index 7a4c1051b7..c7b9e17321 100644 --- a/python/samples/02-agents/tools/tool_in_class.py +++ b/python/samples/02-agents/tools/tool_in_class.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -50,7 +50,7 @@ async def main(): add_function = tool(description="Add two numbers.")(tools.add) agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="ToolAgent", instructions="Use the provided tools.", ) diff --git a/python/samples/02-agents/typed_options.py b/python/samples/02-agents/typed_options.py index 9f2862a65a..b7c22a4151 100644 --- a/python/samples/02-agents/typed_options.py +++ b/python/samples/02-agents/typed_options.py @@ -1,11 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -from typing import Literal from agent_framework import Agent, Message from agent_framework.anthropic import AnthropicClient -from agent_framework.foundry import FoundryChatClient from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions from dotenv import load_dotenv @@ -40,29 +38,29 @@ async def demo_anthropic_chat_client() -> None: print("\n=== Anthropic ChatClient with TypedDict Options ===\n") # Create Anthropic client - client = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client = AnthropicClient(model="claude-sonnet-4-5-20250929") # Standard options work great: response = await client.get_response( - [Message("user", text="What is the capital of France?")], + [Message("user", contents=["What is the capital of France?"])], options={ - "temperature": 0.5, - "max_tokens": 1000, + "temperature": 1, # Must be 1 when thinking is enabled + "max_tokens": 2048, # Anthropic-specific options: - "thinking": {"type": "enabled", "budget_tokens": 1000}, + "thinking": {"type": "enabled", "budget_tokens": 1024}, # "top_k": 40, # <-- Uncomment for Anthropic-specific option }, ) print(f"Anthropic Response: {response.text}") - print(f"Model used: {response.model_id}") + print(f"Model used: {response.model}") async def demo_anthropic_agent() -> None: """Demonstrate Agent with Anthropic client and typed options.""" print("\n=== Agent with Anthropic and Typed Options ===\n") - client = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client = AnthropicClient(model="claude-sonnet-4-5-20250929") # Create a typed agent for Anthropic - IDE knows Anthropic-specific options! agent = Agent( @@ -91,18 +89,12 @@ class OpenAIReasoningChatOptions(OpenAIChatOptions, total=False): Examples: .. code-block:: python - from agent_framework.openai import OpenAIReasoningChatOptions - options: OpenAIReasoningChatOptions = { - "model_id": "o3", - "reasoning_effort": "high", + "reasoning": {"effort": "high"}, "max_tokens": 4096, } """ - # Reasoning-specific parameters - reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"] - # Unsupported parameters for reasoning models (override with None) temperature: None top_p: None @@ -124,12 +116,12 @@ async def demo_openai_chat_client_reasoning_models() -> None: # With specific options, you get full IDE autocomplete! # Try typing `client.get_response("Hello", options={` and see the suggestions response = await client.get_response( - [Message("user", text="What is 2 + 2?")], + [Message("user", contents=["What is 2 + 2?"])], options={ "max_tokens": 100, "allow_multiple_tool_calls": True, # OpenAI-specific options work: - "reasoning_effort": "medium", + "reasoning": {"effort": "medium"}, # Unsupported options are caught by type checker (uncomment to see): # "temperature": 0.7, # "random": 234, @@ -137,7 +129,7 @@ async def demo_openai_chat_client_reasoning_models() -> None: ) print(f"OpenAI Response: {response.text}") - print(f"Model used: {response.model_id}") + print(f"Model used: {response.model}") async def demo_openai_agent() -> None: @@ -149,7 +141,7 @@ async def demo_openai_agent() -> None: # or on the client when constructing the client instance: # client = OpenAIChatClient[OpenAIReasoningChatOptions]() agent = Agent[OpenAIReasoningChatOptions]( - client=FoundryChatClient(model="o3"), + client=OpenAIChatClient(model="o3"), name="weather-assistant", instructions="You are a helpful assistant. Answer concisely.", # Options can be set at construction time @@ -157,7 +149,7 @@ async def demo_openai_agent() -> None: "max_tokens": 100, "allow_multiple_tool_calls": True, # OpenAI-specific options work: - "reasoning_effort": "medium", + "reasoning": {"effort": "medium"}, # Unsupported options are caught by type checker (uncomment to see): # "temperature": 0.7, # "random": 234, @@ -168,7 +160,7 @@ async def demo_openai_agent() -> None: response = await agent.run( "What is 25 * 47?", options={ - "reasoning_effort": "high", # Override for a run + "reasoning": {"effort": "high"}, # Override for a run }, ) diff --git a/python/samples/03-workflows/README.md b/python/samples/03-workflows/README.md index 0e5dbaa9c0..1fd5ab01fc 100644 --- a/python/samples/03-workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -115,7 +115,9 @@ Orchestration-focused samples (Sequential, Concurrent, Handoff, GroupChat, Magen | Sample | File | Concepts | | -------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | | State with Agents | [state-management/state_with_agents.py](./state-management/state_with_agents.py) | Store in state once and later reuse across agents | -| Workflow Kwargs (Custom Context) | [state-management/workflow_kwargs.py](./state-management/workflow_kwargs.py) | Pass custom context (data, user tokens) via kwargs to `@tool` tools | +| Workflow Kwargs - Global Context | [state-management/workflow_kwargs_global.py](./state-management/workflow_kwargs_global.py) | Pass custom context (data, user tokens) via kwargs to `@tool` tools in all agents | +| Workflow Kwargs - Per Agent | [state-management/workflow_kwargs_per_agent.py](./state-management/workflow_kwargs_per_agent.py) | Pass custom context (data, user tokens) via kwargs to `@tool` tools in individual agents | + ### visualization @@ -160,17 +162,17 @@ Sequential orchestration uses a few small adapter nodes for plumbing: These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent’s dispatcher and aggregator and can be ignored if you only care about agent activity. -### AzureOpenAIResponsesClient vs AzureAIAgent +### Why FoundryChatClient? -Workflow and orchestration samples use `AzureOpenAIResponsesClient` rather than the CRUD-style `AzureAIAgent` client. The key difference: +Workflow and orchestration samples use `FoundryChatClient` because they create agents locally and do not need +server-managed agent resources. This lightweight, project-backed chat client is a good fit for orchestration +patterns such as Sequential, Concurrent, Handoff, GroupChat, and Magentic. -- **`AzureOpenAIResponsesClient`** — A lightweight client that uses the underlying Agent Service V2 (Responses API) for non-CRUD-style agents. Orchestrations use this client because agents are created locally and do not require server-side lifecycle management (create/update/delete). This is the recommended client for orchestration patterns (Sequential, Concurrent, Handoff, GroupChat, Magentic). - -- **`AzureAIAgent`** — A CRUD-style client for server-managed agents. Use this when you need persistent, server-side agent definitions with features like file search, code interpreter sessions, or thread management provided by the Azure AI Agent Service. +If you need persistent server-side agent resources, use the hosted-agent flows rather than these workflow samples. ### Environment Variables -Workflow samples that use `AzureOpenAIResponsesClient` expect: +Workflow samples that use `FoundryChatClient` expect: - `FOUNDRY_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) - `FOUNDRY_MODEL` (model deployment name) diff --git a/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py index 1034039a34..ba5c1a729b 100644 --- a/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py @@ -149,7 +149,10 @@ class Coordinator(Executor): # Human approved the draft as-is; forward it unchanged. await ctx.send_message( AgentExecutorRequest( - messages=[*original_request.conversation, *[Message("user", text="The draft is approved as-is.")]], + messages=[ + *original_request.conversation, + *[Message("user", contents=["The draft is approved as-is."])], + ], should_respond=True, ), target_id=self.final_editor_id, @@ -164,7 +167,7 @@ class Coordinator(Executor): "Keep the response under 120 words and reflect any requested tone adjustments." ) await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=instruction)], should_respond=True), + AgentExecutorRequest(messages=[Message("user", contents=[instruction])], should_respond=True), target_id=self.writer_id, ) diff --git a/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py b/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py index d2d02c9581..705b5bacb4 100644 --- a/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py @@ -84,15 +84,17 @@ class Reviewer(Executor): messages = [ Message( role="system", - text=( - "You are a reviewer for an AI agent. Provide feedback on the " - "exchange between a user and the agent. Indicate approval only if:\n" - "- Relevance: response addresses the query\n" - "- Accuracy: information is correct\n" - "- Clarity: response is easy to understand\n" - "- Completeness: response covers all aspects\n" - "Do not approve until all criteria are satisfied." - ), + contents=[ + ( + "You are a reviewer for an AI agent. Provide feedback on the " + "exchange between a user and the agent. Indicate approval only if:\n" + "- Relevance: response addresses the query\n" + "- Accuracy: information is correct\n" + "- Clarity: response is easy to understand\n" + "- Completeness: response covers all aspects\n" + "Do not approve until all criteria are satisfied." + ) + ], ) ] # Add conversation history. diff --git a/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py index 547bb1c6d3..adb43a0921 100644 --- a/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py +++ b/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py @@ -97,7 +97,7 @@ class BriefPreparer(Executor): # Hand the prompt to the writer agent. We always route through the # workflow context so the runtime can capture messages for checkpointing. await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=prompt)], should_respond=True), + AgentExecutorRequest(messages=[Message("user", contents=[prompt])], should_respond=True), target_id=self._agent_id, ) @@ -159,7 +159,7 @@ class ReviewGateway(Executor): f"Human guidance: {reply}" ) await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=prompt)], should_respond=True), + AgentExecutorRequest(messages=[Message("user", contents=[prompt])], should_respond=True), target_id=self._writer_id, ) diff --git a/python/samples/03-workflows/control-flow/edge_condition.py b/python/samples/03-workflows/control-flow/edge_condition.py index e7969b21aa..8cc9d1990e 100644 --- a/python/samples/03-workflows/control-flow/edge_condition.py +++ b/python/samples/03-workflows/control-flow/edge_condition.py @@ -129,7 +129,7 @@ async def to_email_assistant_request( """ # Bridge executor. Converts a structured DetectionResult into a Message and forwards it as a new request. detection = DetectionResult.model_validate_json(response.agent_response.text) - user_msg = Message("user", text=detection.email_content) + user_msg = Message("user", contents=[detection.email_content]) await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True)) @@ -200,7 +200,7 @@ async def main() -> None: # Execute the workflow. Since the start is an AgentExecutor, pass an AgentExecutorRequest. # The workflow completes when it becomes idle (no more work to do). - request = AgentExecutorRequest(messages=[Message("user", text=email)], should_respond=True) + request = AgentExecutorRequest(messages=[Message("user", contents=[email])], should_respond=True) events = await workflow.run(request) outputs = events.get_outputs() if outputs: diff --git a/python/samples/03-workflows/control-flow/multi_selection_edge_group.py b/python/samples/03-workflows/control-flow/multi_selection_edge_group.py index 36c7180642..ebf222d184 100644 --- a/python/samples/03-workflows/control-flow/multi_selection_edge_group.py +++ b/python/samples/03-workflows/control-flow/multi_selection_edge_group.py @@ -97,7 +97,7 @@ async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id) await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=new_email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", contents=[new_email.email_content])], should_respond=True) ) @@ -124,7 +124,7 @@ async def submit_to_email_assistant(analysis: AnalysisResult, ctx: WorkflowConte email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}") await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", contents=[email.email_content])], should_respond=True) ) @@ -139,7 +139,7 @@ async def summarize_email(analysis: AnalysisResult, ctx: WorkflowContext[AgentEx # Only called for long NotSpam emails by selection_func email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}") await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", contents=[email.email_content])], should_respond=True) ) diff --git a/python/samples/03-workflows/control-flow/simple_loop.py b/python/samples/03-workflows/control-flow/simple_loop.py index 2f0734401d..d5368a480a 100644 --- a/python/samples/03-workflows/control-flow/simple_loop.py +++ b/python/samples/03-workflows/control-flow/simple_loop.py @@ -102,7 +102,7 @@ class SubmitToJudgeAgent(Executor): f"Target: {self._target}\nGuess: {guess}\nResponse:" ) await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=prompt)], should_respond=True), + AgentExecutorRequest(messages=[Message("user", contents=[prompt])], should_respond=True), target_id=self._judge_agent_id, ) diff --git a/python/samples/03-workflows/control-flow/switch_case_edge_group.py b/python/samples/03-workflows/control-flow/switch_case_edge_group.py index 9036081a4d..ed183a5143 100644 --- a/python/samples/03-workflows/control-flow/switch_case_edge_group.py +++ b/python/samples/03-workflows/control-flow/switch_case_edge_group.py @@ -104,7 +104,7 @@ async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest # Kick off the detector by forwarding the email as a user message to the spam_detection_agent. await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=new_email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", contents=[new_email.email_content])], should_respond=True) ) @@ -125,7 +125,7 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon # Load the original content from workflow state using the id carried in DetectionResult. email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}") await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", contents=[email.email_content])], should_respond=True) ) diff --git a/python/samples/03-workflows/declarative/customer_support/main.py b/python/samples/03-workflows/declarative/customer_support/main.py index 374825871d..d67adebf1b 100644 --- a/python/samples/03-workflows/declarative/customer_support/main.py +++ b/python/samples/03-workflows/declarative/customer_support/main.py @@ -243,9 +243,9 @@ async def main() -> None: # Load workflow from YAML samples_root = Path(__file__).parent.parent.parent.parent.parent.parent.parent - workflow_path = samples_root / "workflow-samples" / "CustomerSupport.yaml" + workflow_path = samples_root / "declarative-agents" / "workflow-samples" / "CustomerSupport.yaml" if not workflow_path.exists(): - # Fall back to local copy if workflow-samples doesn't exist + # Fall back to local copy if declarative-agents/workflow-samples doesn't exist workflow_path = Path(__file__).parent / "workflow.yaml" workflow = factory.create_workflow_from_yaml_path(workflow_path) diff --git a/python/samples/03-workflows/declarative/deep_research/main.py b/python/samples/03-workflows/declarative/deep_research/main.py index 49e9b86b8a..2eccb44da0 100644 --- a/python/samples/03-workflows/declarative/deep_research/main.py +++ b/python/samples/03-workflows/declarative/deep_research/main.py @@ -190,9 +190,9 @@ async def main() -> None: # Load workflow from YAML samples_root = Path(__file__).parent.parent.parent.parent.parent.parent - workflow_path = samples_root / "workflow-samples" / "DeepResearch.yaml" + workflow_path = samples_root / "declarative-agents" / "workflow-samples" / "DeepResearch.yaml" if not workflow_path.exists(): - # Fall back to local copy if workflow-samples doesn't exist + # Fall back to local copy if declarative-agents/workflow-samples doesn't exist workflow_path = Path(__file__).parent / "workflow.yaml" workflow = factory.create_workflow_from_yaml_path(workflow_path) diff --git a/python/samples/03-workflows/declarative/function_tools/README.md b/python/samples/03-workflows/declarative/function_tools/README.md index e831ba51d7..f7468a7af1 100644 --- a/python/samples/03-workflows/declarative/function_tools/README.md +++ b/python/samples/03-workflows/declarative/function_tools/README.md @@ -6,7 +6,7 @@ This sample demonstrates an agent with function tools responding to user queries The workflow showcases: - **Function Tools**: Agent equipped with tools to query menu data -- **Real Azure OpenAI Agent**: Uses `AzureOpenAIResponsesClient` to create an agent with tools +- **Real Foundry-backed agent**: Uses `FoundryChatClient` to create an agent with tools - **Agent Registration**: Shows how to register agents with the `WorkflowFactory` ## Tools @@ -37,7 +37,7 @@ Drinks: ## Prerequisites -- Azure OpenAI configured with required environment variables +- Microsoft Foundry configured with required environment variables - Authentication via azure-identity (run `az login` before executing) ## Usage @@ -65,16 +65,16 @@ Session Complete ## How It Works -1. Create an Azure OpenAI chat client +1. Create a Foundry chat client 2. Create an agent with instructions and function tools 3. Register the agent with the workflow factory 4. Load the workflow YAML and run it with `run()` and `stream=True` ```python # Create the agent with tools -client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) menu_agent = client.as_agent( diff --git a/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py index 4d45047139..ec7a97aa1b 100644 --- a/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py @@ -103,7 +103,8 @@ class Coordinator(Executor): # Human approved the draft as-is; forward it unchanged. await ctx.send_message( AgentExecutorRequest( - messages=original_request.conversation + [Message("user", text="The draft is approved as-is.")], + messages=original_request.conversation + + [Message("user", contents=["The draft is approved as-is."])], should_respond=True, ), target_id=self.final_editor_name, @@ -118,7 +119,7 @@ class Coordinator(Executor): "Rewrite the draft from the previous assistant message into a polished final version. " "Keep the response under 120 words and reflect any requested tone adjustments." ) - conversation.append(Message("user", text=instruction)) + conversation.append(Message("user", contents=[instruction])) await ctx.send_message( AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_name, diff --git a/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py b/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py index 96dd8ccc52..bb5bb3a711 100644 --- a/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py +++ b/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py @@ -85,13 +85,15 @@ async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any: system_msg = Message( "system", - text=( - "You are a synthesis expert. Consolidate the following analyst perspectives " - "into one cohesive, balanced summary (3-4 sentences). If human guidance is provided, " - "prioritize aspects as directed." - ), + contents=[ + ( + "You are a synthesis expert. Consolidate the following analyst perspectives " + "into one cohesive, balanced summary (3-4 sentences). If human guidance is provided, " + "prioritize aspects as directed." + ) + ], ) - user_msg = Message("user", text="\n\n".join(expert_sections) + guidance_text) + user_msg = Message("user", contents=["\n\n".join(expert_sections) + guidance_text]) response = await _chat_client.get_response([system_msg, user_msg]) return response.messages[-1].text if response.messages else "" diff --git a/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py b/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py index 14f6fa2cb0..0abba476fd 100644 --- a/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py +++ b/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py @@ -91,7 +91,7 @@ class TurnManager(Executor): - Input is a simple starter token (ignored here). - Output is an AgentExecutorRequest that triggers the agent to produce a guess. """ - user = Message("user", text="Start by making your first guess.") + user = Message("user", contents=["Start by making your first guess."]) await ctx.send_message(AgentExecutorRequest(messages=[user], should_respond=True)) @handler @@ -150,7 +150,7 @@ class TurnManager(Executor): f"Feedback: {reply}. Your last guess was {last_guess}. " f"Use this feedback to adjust and make your next guess (1-10)." ) - user_msg = Message("user", text=feedback_text) + user_msg = Message("user", contents=[feedback_text]) await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True)) diff --git a/python/samples/03-workflows/orchestrations/README.md b/python/samples/03-workflows/orchestrations/README.md index 527a414295..1f5f43c00f 100644 --- a/python/samples/03-workflows/orchestrations/README.md +++ b/python/samples/03-workflows/orchestrations/README.md @@ -92,15 +92,17 @@ from agent_framework.orchestrations import ( These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent's dispatcher and aggregator and can be ignored if you only care about agent activity. -## Why AzureOpenAIResponsesClient? +## Why FoundryChatClient? -Orchestration samples use `AzureOpenAIResponsesClient` rather than the CRUD-style `AzureAIAgent` client. Orchestrations create agents locally and do not require server-side lifecycle management (create/update/delete). `AzureOpenAIResponsesClient` is a lightweight client that uses the underlying Agent Service V2 (Responses API) for non-CRUD-style agents, which is ideal for orchestration patterns like Sequential, Concurrent, Handoff, GroupChat, and Magentic. +Orchestration samples use `FoundryChatClient` because they create agents locally and do not require +server-side lifecycle management. `FoundryChatClient` is a lightweight, project-backed client that fits +patterns like Sequential, Concurrent, Handoff, GroupChat, and Magentic. ## Environment Variables -Orchestration samples that use `AzureOpenAIResponsesClient` expect: +Orchestration samples that use `FoundryChatClient` expect: -- `AZURE_AI_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) -- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (model deployment name) +- `FOUNDRY_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) +- `FOUNDRY_MODEL` (model deployment name) These values are passed directly into the client constructor via `os.getenv()` in sample code. diff --git a/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py b/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py index 74afd5df85..8a512f890b 100644 --- a/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py +++ b/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py @@ -82,12 +82,14 @@ async def main() -> None: # Ask the model to synthesize a concise summary of the experts' outputs system_msg = Message( "system", - text=( - "You are a helpful assistant that consolidates multiple domain expert outputs " - "into one cohesive, concise summary with clear takeaways. Keep it under 200 words." - ), + contents=[ + ( + "You are a helpful assistant that consolidates multiple domain expert outputs " + "into one cohesive, concise summary with clear takeaways. Keep it under 200 words." + ) + ], ) - user_msg = Message("user", text="\n\n".join(expert_sections)) + user_msg = Message("user", contents=["\n\n".join(expert_sections)]) response = await client.get_response([system_msg, user_msg]) # Return the model's final assistant text as the completion result diff --git a/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py b/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py index 456ff7e212..4378dd0916 100644 --- a/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py +++ b/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py @@ -49,7 +49,7 @@ class DispatchToExperts(Executor): @handler async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: # Wrap the incoming prompt as a user message for each expert and request a response. - initial_message = Message("user", text=prompt) + initial_message = Message("user", contents=[prompt]) await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True)) diff --git a/python/samples/03-workflows/state-management/state_with_agents.py b/python/samples/03-workflows/state-management/state_with_agents.py index 3c51b6fb9e..5a0d96aabb 100644 --- a/python/samples/03-workflows/state-management/state_with_agents.py +++ b/python/samples/03-workflows/state-management/state_with_agents.py @@ -109,7 +109,7 @@ async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id) await ctx.send_message( - AgentExecutorRequest(messages=[Message("user", text=new_email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", contents=[new_email.email_content])], should_respond=True) ) @@ -140,7 +140,7 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon # Load the original content by id from workflow 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("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", contents=[email.email_content])], should_respond=True) ) diff --git a/python/samples/03-workflows/state-management/workflow_kwargs.py b/python/samples/03-workflows/state-management/workflow_kwargs.py deleted file mode 100644 index 0d50b8710d..0000000000 --- a/python/samples/03-workflows/state-management/workflow_kwargs.py +++ /dev/null @@ -1,148 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import json -import os -from typing import Annotated, Any, cast - -from agent_framework import Agent, Message, tool -from agent_framework.foundry import FoundryChatClient -from agent_framework.orchestrations import SequentialBuilder -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Sample: Workflow kwargs Flow to @tool Tools - -This sample demonstrates how to flow custom context (skill data, user tokens, etc.) -through any workflow pattern to @tool functions using the **kwargs pattern. - -Key Concepts: -- Pass custom context as kwargs when invoking workflow.run() -- kwargs are stored in State and passed to all agent invocations -- @tool functions receive kwargs via **kwargs parameter -- Works with Sequential, Concurrent, GroupChat, Handoff, and Magentic patterns - -Prerequisites: -- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name. -""" - - -# Define tools that accept custom context via **kwargs -# 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_user_data( - query: Annotated[str, Field(description="What user data to retrieve")], - **kwargs: Any, -) -> str: - """Retrieve user-specific data based on the authenticated context.""" - user_token = kwargs.get("user_token", {}) - user_name = user_token.get("user_name", "anonymous") - access_level = user_token.get("access_level", "none") - - print(f"\n[get_user_data] Received kwargs keys: {list(kwargs.keys())}") - print(f"[get_user_data] User: {user_name}") - print(f"[get_user_data] Access level: {access_level}") - - return f"Retrieved data for user {user_name} with {access_level} access: {query}" - - -@tool(approval_mode="never_require") -def call_api( - endpoint_name: Annotated[str, Field(description="Name of the API endpoint to call")], - **kwargs: Any, -) -> str: - """Call an API using the configured endpoints from custom_data.""" - custom_data = kwargs.get("custom_data", {}) - api_config = custom_data.get("api_config", {}) - - base_url = api_config.get("base_url", "unknown") - endpoints = api_config.get("endpoints", {}) - - print(f"\n[call_api] Received kwargs keys: {list(kwargs.keys())}") - print(f"[call_api] Base URL: {base_url}") - print(f"[call_api] Available endpoints: {list(endpoints.keys())}") - - if endpoint_name in endpoints: - return f"Called {base_url}{endpoints[endpoint_name]} successfully" - return f"Endpoint '{endpoint_name}' not found in configuration" - - -async def main() -> None: - print("=" * 70) - print("Workflow kwargs Flow Demo (SequentialBuilder)") - print("=" * 70) - - # Create chat client - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) - - # Create agent with tools that use kwargs - agent = Agent( - client=client, - name="assistant", - instructions=( - "You are a helpful assistant. Use the available tools to help users. " - "When asked about user data, use get_user_data. " - "When asked to call an API, use call_api." - ), - tools=[get_user_data, call_api], - ) - - # Build a simple sequential workflow - workflow = SequentialBuilder(participants=[agent]).build() - - # Define custom context that will flow to tools via kwargs - custom_data = { - "api_config": { - "base_url": "https://api.example.com", - "endpoints": { - "users": "/v1/users", - "orders": "/v1/orders", - "products": "/v1/products", - }, - }, - } - - user_token = { - "user_name": "bob@contoso.com", - "access_level": "admin", - } - - print("\nCustom Data being passed:") - print(json.dumps(custom_data, indent=2)) - print(f"\nUser: {user_token['user_name']}") - print("\n" + "-" * 70) - print("Workflow Execution (watch for [tool_name] logs showing kwargs received):") - print("-" * 70) - - # Run workflow with kwargs - these will flow through to tools - async for event in workflow.run( - "Please get my user data and then call the users API endpoint.", - additional_function_arguments={"custom_data": custom_data, "user_token": user_token}, - stream=True, - ): - if event.type == "output": - output_data = cast(list[Message], event.data) - if isinstance(output_data, list): - for item in output_data: - if isinstance(item, Message) and item.text: - print(f"\n[Final Answer]: {item.text}") - - print("\n" + "=" * 70) - print("Sample Complete") - print("=" * 70) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/03-workflows/state-management/workflow_kwargs_global.py b/python/samples/03-workflows/state-management/workflow_kwargs_global.py new file mode 100644 index 0000000000..89f0c1672e --- /dev/null +++ b/python/samples/03-workflows/state-management/workflow_kwargs_global.py @@ -0,0 +1,170 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +import os +from typing import Annotated, Any, cast + +from agent_framework import Agent, Message, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Sample: Global Workflow kwargs + +This sample demonstrates how to pass the same kwargs to every agent in a +workflow using global targeting. When keys in function_invocation_kwargs do NOT +match any executor ID (agent name), the framework treats them as global and +delivers them to all agents. + +Compare with workflow_kwargs_per_agent.py which targets kwargs to specific agents. + +Key Concepts: +- Global function_invocation_kwargs are delivered to every agent in the workflow +- Useful when all agents share the same credentials, config, or context +- @tool functions receive kwargs via the **kwargs parameter + +Prerequisites: +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured +""" + + +# 1. Define a tool for the research agent — queries a company's internal +# database using credentials passed via global kwargs. +# 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 query_company_database( + query: Annotated[ + str, Field(description="The database query to run, e.g. 'Q3 revenue' or 'headcount by department'") + ], + **kwargs: Any, +) -> str: + """Query the company's internal database for business metrics and data.""" + db_config = kwargs.get("db_config", {}) + connection_string = db_config.get("connection_string", "") + database = db_config.get("database", "") + + if not connection_string or not database: + return f"ERROR: missing db_config — cannot run query '{query}'" + + print(f"\n [query_company_database] Connecting to {database} at {connection_string[:30]}...") + + # Simulated company data that the LLM would not know on its own + return ( + f"Query results from {database}:\n" + f"- Contoso Q3 2025 revenue: $47.2M (up 12% YoY)\n" + f"- Top product line: CloudSync Pro ($18.6M)\n" + f"- Engineering headcount: 342 (up from 298 in Q2)\n" + f"- Customer churn rate: 4.1% (down from 5.3% in Q2)\n" + f"- Net new enterprise customers: 28" + ) + + +# 2. Define a tool for the writer agent — retrieves the formatting style +# from user preferences passed via global kwargs. +@tool(approval_mode="never_require") +def get_formatting_instructions( + section_title: Annotated[str, Field(description="The title of the section or report to format")], + **kwargs: Any, +) -> str: + """Get the formatting instructions based on user preferences.""" + user_prefs = kwargs.get("user_preferences", {}) + output_format = user_prefs.get("format", "plain") + language = user_prefs.get("language", "en") + + print(f"\n [get_formatting_instructions] Format: {output_format}, Language: {language}") + + return ( + f"Formatting rules for '{section_title}':\n" + f"- Output format: {output_format}\n" + f"- Language/locale: {language}\n" + f"- Include a footer: 'Generated in {output_format} for locale {language}'" + ) + + +async def main() -> None: + print("=" * 70) + print("Global Workflow kwargs Demo") + print("=" * 70) + + # 3. Create a shared chat client. + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + # 4. Create two agents with different tools and responsibilities. + researcher = Agent( + client=client, + name="researcher", + instructions=( + "You are a data analyst. Call query_company_database exactly once " + "with the user's request as the query. Return the raw results." + ), + tools=[query_company_database], + ) + + writer = Agent( + client=client, + name="writer", + instructions=( + "You are a report writer. Call get_formatting_instructions exactly once, " + "then rewrite the data you receive into a polished report following those rules." + ), + tools=[get_formatting_instructions], + ) + + # 5. Build a sequential workflow: researcher -> writer. + workflow = SequentialBuilder(participants=[researcher, writer]).build() + + # 6. Define global kwargs — every agent receives all of these. + # Because the keys ("db_config", "user_preferences") do NOT match any + # executor ID ("researcher", "writer"), the framework treats them as + # global and delivers the full dict to every agent. + global_fi_kwargs = { + "db_config": { + "connection_string": "Server=contoso-sql.database.windows.net;Database=metrics", + "database": "contoso_metrics_prod", + }, + "user_preferences": { + "format": "markdown", + "language": "en-US", + }, + } + + print("\nGlobal function_invocation_kwargs (sent to all agents):") + print(json.dumps(global_fi_kwargs, indent=2)) + print("\n" + "-" * 70) + print("Workflow Execution:") + print("-" * 70) + + # 7. Run the workflow — every agent receives the same global kwargs. + async for event in workflow.run( + "Pull Contoso's Q3 2025 performance data and write an executive summary.", + function_invocation_kwargs=global_fi_kwargs, + stream=True, + ): + if event.type == "output": + output_data = cast(list[Message], event.data) + if isinstance(output_data, list): + for item in output_data: + if isinstance(item, Message) and item.text: + print(f"\n[{item.author_name}]: {item.text}") + + print("\n" + "=" * 70) + print("Sample Complete") + print("=" * 70) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/state-management/workflow_kwargs_per_agent.py b/python/samples/03-workflows/state-management/workflow_kwargs_per_agent.py new file mode 100644 index 0000000000..d35d409301 --- /dev/null +++ b/python/samples/03-workflows/state-management/workflow_kwargs_per_agent.py @@ -0,0 +1,222 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +import os +from typing import Annotated, Any, cast + +from agent_framework import Agent, Message, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Sample: Per-Agent Workflow kwargs + +This sample demonstrates how to pass different kwargs to different agents in a +workflow using per-agent targeting. When keys in function_invocation_kwargs (or +client_kwargs) match executor IDs (agent names by default), each agent +receives only its own slice of the kwargs. + +Key Concepts: +- Per-agent function_invocation_kwargs target specific agents by executor ID +- Agents only receive the kwargs assigned to them (not other agents' kwargs) +- Useful when different agents need different credentials, configs, or context + +Prerequisites: +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured +""" + + +# 1. Define a tool for the research agent — queries a company's internal +# database using credentials passed via per-agent kwargs. +# 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 query_company_database( + query: Annotated[ + str, Field(description="The database query to run, e.g. 'Q3 revenue' or 'headcount by department'") + ], + **kwargs: Any, +) -> str: + """Query the company's internal database for business metrics and data.""" + db_config = kwargs.get("db_config", {}) + connection_string = db_config.get("connection_string", "") + database = db_config.get("database", "") + + if not connection_string or not database: + return f"ERROR: missing db_config — cannot run query '{query}'" + + print(f"\n [query_company_database] Connecting to {database} at {connection_string[:30]}...") + + # Simulated company data that the LLM would not know on its own + return ( + f"Query results from {database}:\n" + f"- Contoso Q3 2025 revenue: $47.2M (up 12% YoY)\n" + f"- Top product line: CloudSync Pro ($18.6M)\n" + f"- Engineering headcount: 342 (up from 298 in Q2)\n" + f"- Customer churn rate: 4.1% (down from 5.3% in Q2)\n" + f"- Net new enterprise customers: 28" + ) + + +# 2. Define a tool for the writer agent — retrieves the formatting style +# from user preferences passed via per-agent kwargs. +@tool(approval_mode="never_require") +def get_formatting_instructions( + section_title: Annotated[str, Field(description="The title of the section or report to format")], + **kwargs: Any, +) -> str: + """Get the formatting instructions based on user preferences.""" + user_prefs = kwargs.get("user_preferences", {}) + output_format = user_prefs.get("format", "plain") + language = user_prefs.get("language", "en") + + print(f"\n [get_formatting_instructions] Format: {output_format}, Language: {language}") + + return ( + f"Formatting rules for '{section_title}':\n" + f"- Output format: {output_format}\n" + f"- Language/locale: {language}\n" + f"- Include a footer: 'Generated in {output_format} for locale {language}'" + ) + + +async def main() -> None: + print("=" * 70) + print("Per-Agent Workflow kwargs Demo") + print("=" * 70) + + # 3. Create a shared chat client. + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + # 4. Create two agents with different tools and responsibilities. + researcher = Agent( + client=client, + name="researcher", + instructions=( + "You are a data analyst. Call query_company_database exactly once " + "with the user's request as the query. Return the raw results." + ), + tools=[query_company_database], + ) + + writer = Agent( + client=client, + name="writer", + instructions=( + "You are a report writer. Call get_formatting_instructions exactly once, " + "then rewrite the data you receive into a polished report following those rules." + ), + tools=[get_formatting_instructions], + ) + + # 5. Build a sequential workflow: researcher -> writer. + workflow = SequentialBuilder(participants=[researcher, writer]).build() + + # 6. Define per-agent kwargs — each agent gets only its own config. + # The keys ("researcher", "writer") match the agent names, which are + # used as executor IDs by default. + per_agent_fi_kwargs = { + "researcher": { + "db_config": { + "connection_string": "Server=contoso-sql.database.windows.net;Database=metrics", + "database": "contoso_metrics_prod", + }, + }, + "writer": { + "user_preferences": { + "format": "markdown", + "language": "en-US", + }, + }, + } + + print("\nPer-agent function_invocation_kwargs:") + print(json.dumps(per_agent_fi_kwargs, indent=2)) + print("\n" + "-" * 70) + print("Workflow Execution:") + print("-" * 70) + + # 7. Run the workflow — each agent receives only its targeted kwargs. + async for event in workflow.run( + "Pull Contoso's Q3 2025 performance data and write an executive summary.", + function_invocation_kwargs=per_agent_fi_kwargs, + stream=True, + ): + if event.type == "output": + output_data = cast(list[Message], event.data) + if isinstance(output_data, list): + for item in output_data: + if isinstance(item, Message) and item.text: + print(f"\n[{item.author_name}]: {item.text}") + + print("\n" + "=" * 70) + print("Sample Complete") + print("=" * 70) + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +Per-agent function_invocation_kwargs: +{ + "researcher": { + "db_config": { + "connection_string": "Server=contoso-sql.database.windows.net;Database=metrics", + "database": "contoso_metrics_prod" + } + }, + "writer": { + "user_preferences": { + "format": "markdown", + "language": "en-US" + } + } +} + +---------------------------------------------------------------------- +Workflow Execution: +---------------------------------------------------------------------- + + [query_company_database] Connecting to contoso_metrics_prod at Server=contoso-sql.database.wi... + +[researcher]: Here is Contoso's Q3 2025 data: +- Revenue: $47.2M (up 12% YoY) +- Top product: CloudSync Pro ($18.6M) +- Engineering headcount: 342 +- Churn rate: 4.1% +- Net new enterprise customers: 28 + + [get_formatting_instructions] Format: markdown, Language: en-US + +[writer]: # Contoso Q3 2025 Executive Summary + +| Metric | Value | +|---|---| +| Revenue | $47.2M (+12% YoY) | +| Top Product | CloudSync Pro ($18.6M) | +| Engineering Headcount | 342 | +| Customer Churn | 4.1% | +| New Enterprise Customers | 28 | + +Generated in markdown for locale en-US + +====================================================================== +Sample Complete +====================================================================== +""" diff --git a/python/samples/03-workflows/visualization/concurrent_with_visualization.py b/python/samples/03-workflows/visualization/concurrent_with_visualization.py index d59268540e..2af15a16d0 100644 --- a/python/samples/03-workflows/visualization/concurrent_with_visualization.py +++ b/python/samples/03-workflows/visualization/concurrent_with_visualization.py @@ -46,7 +46,7 @@ class DispatchToExperts(Executor): @handler async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: # Wrap the incoming prompt as a user message for each expert and request a response. - initial_message = Message("user", text=prompt) + initial_message = Message("user", contents=[prompt]) await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True)) diff --git a/python/samples/04-hosting/a2a/README.md b/python/samples/04-hosting/a2a/README.md index 0affc84e19..f377eed8ba 100644 --- a/python/samples/04-hosting/a2a/README.md +++ b/python/samples/04-hosting/a2a/README.md @@ -1,11 +1,12 @@ # A2A Agent Examples -This sample demonstrates how to host and consume agents using the [A2A (Agent2Agent) protocol](https://a2a-protocol.org/latest/) with the `agent_framework` package. There are two runnable entry points: +This sample demonstrates how to host and consume agents using the [A2A (Agent2Agent) protocol](https://a2a-protocol.org/latest/) with the `agent_framework` package. There are three runnable entry points: | Run this file | To... | |---------------|-------| | **[`a2a_server.py`](a2a_server.py)** | Host an Agent Framework agent as an A2A-compliant server. | | **[`agent_with_a2a.py`](agent_with_a2a.py)** | Connect to an A2A server and send requests (non-streaming and streaming). | +| **[`a2a_agent_as_function_tools.py`](a2a_agent_as_function_tools.py)** | Convert A2A agent skills into function tools for a host agent. | The remaining files are supporting modules used by the server: @@ -21,12 +22,17 @@ The remaining files are supporting modules used by the server: Make sure to set the following environment variables before running the examples: ### Required (Server) -- `AZURE_AI_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` — Model deployment name (e.g. `gpt-4o`) +- `FOUNDRY_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL` — Model deployment name (e.g. `gpt-4o`) ### Required (Client) - `A2A_AGENT_HOST` — URL of the A2A server (e.g. `http://localhost:5001/`) +### Required (Function Tools Sample) +- `A2A_AGENT_HOST` — URL of the A2A server (e.g. `http://localhost:5000/`) +- `FOUNDRY_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL` — Model deployment name (e.g. `gpt-4o`) + ## Quick Start All commands below should be run from this directory: @@ -55,3 +61,14 @@ In a separate terminal (from the same directory), point the client at a running $env:A2A_AGENT_HOST = "http://localhost:5001/" uv run python agent_with_a2a.py ``` + +### 3. Run the Function Tools Sample + +This sample resolves the remote agent's skills and registers each one as a function tool +on a host Foundry-backed agent. The host agent then autonomously selects the right skill +to handle the user's request. + +```powershell +$env:A2A_AGENT_HOST = "http://localhost:5000/" +uv run python a2a_agent_as_function_tools.py +``` diff --git a/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py b/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py new file mode 100644 index 0000000000..ca753f2d42 --- /dev/null +++ b/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +import re + +import httpx +from a2a.client import A2ACardResolver +from agent_framework.a2a import A2AAgent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +A2A Agent Skills as Function Tools + +This sample demonstrates how to represent an A2A agent's skills as individual +function tools and register them with a host agent. Each skill advertised in the +remote agent's AgentCard becomes a separate tool that the host agent can invoke. + +Key concepts demonstrated: +- Resolving an AgentCard from a remote A2A endpoint +- Converting each skill into a FunctionTool via as_tool() +- Registering those tools with a host agent +- Having the host agent autonomously select and invoke A2A skills + +Prerequisites: +- Set A2A_AGENT_HOST to the URL of a running A2A server +- Set FOUNDRY_PROJECT_ENDPOINT to your Azure AI Foundry project endpoint +- Set FOUNDRY_MODEL to the model deployment name (e.g. gpt-4o) + +To run this sample: + cd python/samples/04-hosting/a2a + uv run python a2a_agent_as_function_tools.py +""" + + +async def main() -> None: + """Discover A2A agent skills and register them as tools on a host agent.""" + # 1. Read environment configuration. + a2a_agent_host = os.getenv("A2A_AGENT_HOST") + if not a2a_agent_host: + raise ValueError("A2A_AGENT_HOST environment variable is not set") + + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + if not project_endpoint or not model: + raise ValueError("FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL must be set") + + print(f"Connecting to A2A agent at: {a2a_agent_host}") + + # 2. Resolve the remote agent card to discover its skills. + async with httpx.AsyncClient(timeout=60.0) as http_client: + resolver = A2ACardResolver(httpx_client=http_client, base_url=a2a_agent_host) + agent_card = await resolver.get_agent_card() + + print(f"Found agent: {agent_card.name} ({len(agent_card.skills)} skill(s))") + for skill in agent_card.skills: + print(f" - {skill.name}: {skill.description}") + + # 3. Create the A2AAgent that wraps the remote endpoint. + async with A2AAgent( + name=agent_card.name, + description=agent_card.description, + agent_card=agent_card, + url=a2a_agent_host, + ) as a2a_agent: + # 4. Convert each A2A skill into a FunctionTool. + # Skill names may contain spaces or special characters, so we + # sanitize them into valid tool identifiers before passing to as_tool(). + skill_tools = [ + a2a_agent.as_tool( + name=re.sub(r"[^0-9A-Za-z]+", "_", skill.name), + description=skill.description or "", + ) + for skill in agent_card.skills + ] + + # 5. Create the host agent with the skill tools. + credential = AzureCliCredential() + client = FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ) + host_agent = client.as_agent( + name="assistant", + instructions="You are a helpful assistant. Use your tools to answer questions.", + tools=skill_tools, + ) + + # 6. Run the host agent — it will select and invoke the appropriate A2A skill tools. + query = "Show me all invoices for Contoso" + print(f"\nUser: {query}\n") + response = await host_agent.run(query) + print(f"Agent: {response}") + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: + +Connecting to A2A agent at: http://localhost:5000/ +Found agent: InvoiceAgent (1 skill(s)) + - InvoiceQuery: Handles requests relating to invoices. + +User: Show me all invoices for Contoso + +Agent: Here are the invoices for Contoso: + +1. **Invoice ID:** INV789 + - **Date:** 2026-02-15 + - **Products:** + - T-Shirts: 150 units @ $10.00 = $1,500.00 + - Hats: 200 units @ $15.00 = $3,000.00 + - Glasses: 300 units @ $5.00 = $1,500.00 + - **Total:** $6,000.00 + +2. **Invoice ID:** INV333 + - **Date:** 2026-03-14 + - **Products:** + - T-Shirts: 400 units @ $11.00 = $4,400.00 + - Hats: 600 units @ $15.00 = $9,000.00 + - Glasses: 700 units @ $5.00 = $3,500.00 + - **Total:** $16,900.00 + +3. **Invoice ID:** INV666 + - **Date:** 2026-02-06 + - **Products:** + - T-Shirts: 2,500 units @ $8.00 = $20,000.00 + - Hats: 1,200 units @ $10.00 = $12,000.00 + - Glasses: 1,000 units @ $6.00 = $6,000.00 + - **Total:** $38,000.00 + +4. **Invoice ID:** INV999 + - **Date:** 2026-03-19 + - **Products:** + - T-Shirts: 1,400 units @ $10.50 = $14,700.00 + - Hats: 1,100 units @ $9.00 = $9,900.00 + - Glasses: 950 units @ $12.00 = $11,400.00 + - **Total:** $36,000.00 + +If you need more details or a specific invoice, please let me know! +""" diff --git a/python/samples/04-hosting/a2a/a2a_server.py b/python/samples/04-hosting/a2a/a2a_server.py index de3f361457..0bd0b29dc5 100644 --- a/python/samples/04-hosting/a2a/a2a_server.py +++ b/python/samples/04-hosting/a2a/a2a_server.py @@ -67,12 +67,12 @@ def main() -> None: # Validate environment project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") - deployment_name = os.getenv("FOUNDRY_MODEL") + model = os.getenv("FOUNDRY_MODEL") if not project_endpoint: print("Error: FOUNDRY_PROJECT_ENDPOINT environment variable is not set.") sys.exit(1) - if not deployment_name: + if not model: print("Error: FOUNDRY_MODEL environment variable is not set.") sys.exit(1) @@ -80,7 +80,7 @@ def main() -> None: credential = AzureCliCredential() client = FoundryChatClient( project_endpoint=project_endpoint, - model=deployment_name, + model=model, credential=credential, ) 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 index 9771a27f70..e9e2cb05f3 100644 --- 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 @@ -7,7 +7,7 @@ Components used in this sample: - 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_DEPLOYMENT_NAME`, and sign in with Azure CLI before starting the Functions host.""" +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 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 index ec085a8582..71c963a25b 100644 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/README.md +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md @@ -138,23 +138,35 @@ Expected response: The sample shows how to enable MCP tool triggers with flexible agent configuration: ```python -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +import os -# Create Azure OpenAI Chat Client -client = AzureOpenAIChatClient() +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 = client.as_agent( +joker_agent = Agent( + client=client, name="Joker", instructions="You are good at telling jokes.", ) -stock_agent = client.as_agent( +stock_agent = Agent( + client=client, name="StockAdvisor", instructions="Check stock prices.", ) -plant_agent = client.as_agent( +plant_agent = Agent( + client=client, name="PlantAdvisor", instructions="Recommend plants.", description="Get plant recommendations.", 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 index 16f535af0c..e4c5110ac1 100644 --- 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 @@ -110,7 +110,7 @@ async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id) await ctx.send_message( - AgentExecutorRequest(messages=[Message(role="user", text=new_email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message(role="user", contents=[new_email.email_content])], should_respond=True) ) @@ -146,7 +146,7 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon # 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", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message(role="user", contents=[email.email_content])], should_respond=True) ) 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 index bfea3f5279..f39a3a2933 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template @@ -10,4 +10,4 @@ TASKHUB_NAME=default # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_MODEL=your-deployment-name 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 index 99afd35edb..e12cec0046 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md @@ -99,7 +99,7 @@ The sample can run locally without Azure Functions infrastructure using DevUI: ``` 2. Configure `.env` with your Azure OpenAI credentials (`AZURE_OPENAI_ENDPOINT` and - `AZURE_OPENAI_DEPLOYMENT_NAME`) + `AZURE_OPENAI_MODEL`) 3. Install dependencies: ```bash 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 index 2ea10e8ce7..7deea4211c 100644 --- 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 @@ -21,7 +21,7 @@ Key architectural points: - Mixed agent/executor fan-outs execute concurrently Prerequisites: -- Configure `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME` +- 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 """ @@ -362,7 +362,7 @@ def _create_workflow() -> Workflow: credential = AzureCliCredential() chat_client = OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default"), ) 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 index 23140d3eec..5b65dd278f 100644 --- 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 @@ -6,6 +6,6 @@ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_DEPLOYMENT_NAME": "" + "AZURE_OPENAI_MODEL": "" } } 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 index 3680df202f..e1f9389a6d 100644 --- 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 @@ -358,7 +358,7 @@ class InputRouterExecutor(Executor): await ctx.send_message( AgentExecutorRequest( - messages=[Message(role="user", text=message)], + messages=[Message(role="user", contents=[message])], should_respond=True, ) ) diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/client.py b/python/samples/04-hosting/durabletask/02_multi_agent/client.py index 81933de8ee..693e6dcfe0 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/client.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/client.py @@ -8,7 +8,7 @@ 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_DEPLOYMENT_NAME when running the worker +- 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 """ diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py index 4ef01fe400..e847abbd64 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py @@ -5,7 +5,7 @@ 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_DEPLOYMENT_NAME +- 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: diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py index 9183e9ee61..36bb96a555 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py @@ -7,7 +7,7 @@ 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_DEPLOYMENT_NAME +- 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) """ 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 index caa3ca03f5..cc6879fc76 100644 --- 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 @@ -17,7 +17,7 @@ See the [README.md](../README.md) file in the parent directory for more informat This sample uses Azure OpenAI credentials: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` ## Running the Sample 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 index 2eac694ed6..da42ad6d45 100644 --- 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 @@ -7,7 +7,7 @@ that uses conditional logic to either handle spam emails or draft professional r Prerequisites: - The worker must be running with both agents, orchestration, and activities registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ 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 index a00e075d4a..f243b2ccb7 100644 --- 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 @@ -10,7 +10,7 @@ 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_DEPLOYMENT_NAME +- 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) 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 index e9cda191f4..0b5f014873 100644 --- 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 @@ -7,7 +7,7 @@ orchestration function that routes execution based on spam detection results. Ac handle side effects (spam handling and email sending). Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- 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) """ @@ -69,7 +69,7 @@ def create_spam_agent() -> "Agent": """ return Agent( client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], api_key=get_async_bearer_token_provider( AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" ), @@ -87,7 +87,7 @@ def create_email_agent() -> "Agent": """ return Agent( client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], api_key=get_async_bearer_token_provider( AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" ), diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md index 51e1c9fc1c..099c1b6c14 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md @@ -27,8 +27,8 @@ The backend uses Azure OpenAI responses and supports intent-driven, non-linear h - Node.js 18+ - npm 9+ - Azure AI project + model deployment configured in environment variables: - - `AZURE_AI_PROJECT_ENDPOINT` - - `AZURE_AI_MODEL_DEPLOYMENT_NAME` + - `FOUNDRY_PROJECT_ENDPOINT` + - `FOUNDRY_MODEL` ## 1) Run Backend diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py index 3a19b941fc..02329e8e16 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py @@ -85,7 +85,7 @@ def create_agents() -> tuple[Agent, Agent, Agent]: client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/chatkit-integration/README.md b/python/samples/05-end-to-end/chatkit-integration/README.md index 692145196e..5a8639fa51 100644 --- a/python/samples/05-end-to-end/chatkit-integration/README.md +++ b/python/samples/05-end-to-end/chatkit-integration/README.md @@ -177,7 +177,7 @@ pip install agent-framework-chatkit fastapi uvicorn azure-identity ```bash export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" export AZURE_OPENAI_API_VERSION="2024-06-01" -export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o" +export AZURE_OPENAI_MODEL="gpt-4o" ``` 3. **Authenticate with Azure:** diff --git a/python/samples/05-end-to-end/chatkit-integration/app.py b/python/samples/05-end-to-end/chatkit-integration/app.py index b0084931c6..e509fe1896 100644 --- a/python/samples/05-end-to-end/chatkit-integration/app.py +++ b/python/samples/05-end-to-end/chatkit-integration/app.py @@ -296,11 +296,13 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): title_prompt = [ Message( role=Role.USER, - text=( - f"Generate a very short, concise title (max 40 characters) for a conversation " - f"that starts with:\n\n{conversation_context}\n\n" - "Respond with ONLY the title, nothing else." - ), + contents=[ + ( + f"Generate a very short, concise title (max 40 characters) for a conversation " + f"that starts with:\n\n{conversation_context}\n\n" + "Respond with ONLY the title, nothing else." + ) + ], ) ] @@ -472,7 +474,7 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): weather_data: WeatherData | None = None # Create an agent message asking about the weather - agent_messages = [Message(role=Role.USER, text=f"What's the weather in {city_label}?")] + agent_messages = [Message(role=Role.USER, contents=[f"What's the weather in {city_label}?"])] logger.debug(f"Processing weather query: {agent_messages[0].text}") diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py index 1135077f5c..7c8b306c11 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py @@ -10,7 +10,7 @@ See ``evaluate_tool_calls_sample.py`` for tool-call accuracy evaluation. Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py index b15781e2bd..2f26eb2e3e 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py @@ -48,7 +48,7 @@ async def main() -> None: # 1. Set up the chat client chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py index b28bba22c0..863011241f 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py @@ -11,7 +11,7 @@ a different aspect of agent behavior: Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -94,7 +94,7 @@ def print_split(item: EvalItem, split: ConversationSplit = ConversationSplit.LAS async def main() -> None: chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py index 4b5d892fe4..ef51afc0c6 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py @@ -7,7 +7,7 @@ by using ``FoundryEvals.evaluate()`` with ``TOOL_CALL_ACCURACY``. Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -39,7 +39,7 @@ def get_flight_price(origin: str, destination: str) -> str: async def main() -> None: chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) @@ -48,8 +48,7 @@ async def main() -> None: client=chat_client, name="travel-assistant", instructions=( - "You are a helpful travel assistant. " - "Use your tools to answer questions about weather and flights." + "You are a helpful travel assistant. Use your tools to answer questions about weather and flights." ), tools=[get_weather, get_flight_price], ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py index e0d4d07950..5a1ebeeb57 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py @@ -13,7 +13,7 @@ Prerequisites: - An Azure AI Foundry project with a deployed model - Response IDs from prior agent runs (for Pattern 1) - OTel traces exported to App Insights (for Pattern 2) -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -30,7 +30,7 @@ async def main() -> None: # 1. Set up the chat client chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py index 5f0a7315dc..e2861324f6 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py @@ -11,7 +11,7 @@ breakdown in sub_results so you can identify which agent is underperforming. Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -46,7 +46,7 @@ async def main() -> None: # 1. Set up the chat client client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/.env.example b/python/samples/05-end-to-end/evaluation/red_teaming/.env.example index c19da5af2a..584c630542 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/.env.example +++ b/python/samples/05-end-to-end/evaluation/red_teaming/.env.example @@ -1,8 +1,8 @@ # Azure OpenAI Configuration (for the agent being tested) AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_MODEL=gpt-4o # AZURE_OPENAI_API_KEY=your-api-key-here # Azure AI Project Configuration (for red teaming) # Create these resources at: https://portal.azure.com -AZURE_AI_PROJECT_ENDPOINT=your-ai-project-name +FOUNDRY_PROJECT_ENDPOINT=your-ai-project-name diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/README.md b/python/samples/05-end-to-end/evaluation/red_teaming/README.md index 9a9efaafe6..c72713356b 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/README.md +++ b/python/samples/05-end-to-end/evaluation/red_teaming/README.md @@ -11,7 +11,7 @@ For more details on the Red Team setup see [the Azure AI Foundry docs](https://l A focused sample demonstrating Azure AI's RedTeam functionality to assess the safety and resilience of Agent Framework agents against adversarial attacks. **What it demonstrates:** -1. Creating a financial advisor agent inline using `AzureOpenAIChatClient` +1. Creating a financial advisor agent inline using `FoundryChatClient` 2. Setting up an async callback to interface the agent with RedTeam evaluator 3. Running comprehensive evaluations with 11 different attack strategies: - Basic: EASY and MODERATE difficulty levels @@ -43,11 +43,11 @@ Create a `.env` file in this directory or set these environment variables: ```bash # Azure OpenAI (for the agent being tested) AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_MODEL=gpt-4o # AZURE_OPENAI_API_KEY is optional if using Azure CLI authentication # Azure AI Project (for red teaming) -AZURE_AI_PROJECT_ENDPOINT=https://your-project.api.azureml.ms +FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms ``` See `.env.example` for a template. @@ -113,7 +113,7 @@ async def main() -> None: credential = AzureCliCredential() # 2. Create agent inline - agent = AzureOpenAIChatClient(credential=credential).as_agent( + agent = FoundryChatClient(credential=credential).as_agent( model="gpt-4o", instructions="You are a helpful financial advisor..." ) @@ -125,7 +125,7 @@ async def main() -> None: # 4. Run red team scan with multiple strategies red_team = RedTeam( - azure_ai_project=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + azure_ai_project=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential ) results = await red_team.scan( diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py b/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py index d0edf632d8..03f22dc79a 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py +++ b/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py @@ -51,7 +51,7 @@ async def main() -> None: credential = AzureCliCredential() # Create the agent # Constructor automatically reads from environment variables: - # AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT_NAME, AZURE_OPENAI_API_KEY + # AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_MODEL, AZURE_OPENAI_API_KEY agent = Agent( client=FoundryChatClient(credential=credential), name="FinancialAdvisor", @@ -83,7 +83,7 @@ Your boundaries: Args: messages: The adversarial prompts from RedTeam """ - messages_list = [Message(role=message.role, text=message.content) for message in messages] + messages_list = [Message(role=message.role, contents=[message.content]) for message in messages] try: response = agent.run(messages=messages_list, stream=stream) result = await response.get_final_response() if stream else await response diff --git a/python/samples/05-end-to-end/hosted_agents/README.md b/python/samples/05-end-to-end/hosted_agents/README.md index 4f067ee4ab..d45b2f630c 100644 --- a/python/samples/05-end-to-end/hosted_agents/README.md +++ b/python/samples/05-end-to-end/hosted_agents/README.md @@ -7,10 +7,10 @@ These samples demonstrate how to build and host AI agents in Python using the [A | Sample | Description | | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | [`agent_with_hosted_mcp`](./agent_with_hosted_mcp/) | Hosted MCP tool that connects to Microsoft Learn via `https://learn.microsoft.com/api/mcp` | -| [`agent_with_text_search_rag`](./agent_with_text_search_rag/) | Retrieval-augmented generation using a custom `BaseContextProvider` with Contoso Outdoors sample data | +| [`agent_with_text_search_rag`](./agent_with_text_search_rag/) | Retrieval-augmented generation using a custom `ContextProvider` with Contoso Outdoors sample data | | [`agents_in_workflow`](./agents_in_workflow/) | Concurrent workflow that combines researcher, marketer, and legal specialist agents | | [`agent_with_local_tools`](./agent_with_local_tools/) | Local Python tool execution for Seattle hotel search | -| [`writer_reviewer_agents_in_workflow`](./writer_reviewer_agents_in_workflow/) | Writer/Reviewer workflow using `AzureOpenAIResponsesClient` | +| [`writer_reviewer_agents_in_workflow`](./writer_reviewer_agents_in_workflow/) | Writer/Reviewer workflow using `FoundryChatClient` | ## Common Prerequisites @@ -76,14 +76,14 @@ Example `.env` for Azure OpenAI samples: ```dotenv AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4.1 +AZURE_OPENAI_MODEL=gpt-4.1 ``` Example `.env` for Foundry project samples: ```dotenv -PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -MODEL_DEPLOYMENT_NAME=gpt-4.1 +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4.1 ``` ## Interacting with the Agent diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml index 5a0f58554d..6e46adcaed 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml @@ -22,7 +22,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + - name: AZURE_OPENAI_MODEL value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py index fe6d4648c9..8d87046fa3 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py @@ -11,18 +11,20 @@ load_dotenv() def main(): + client = FoundryChatClient(credential=AzureCliCredential()) + # Create MCP tool configuration as dict - mcp_tool = { - "type": "mcp", - "server_label": "Microsoft_Learn_MCP", - "server_url": "https://learn.microsoft.com/api/mcp", - } + mcp_tool = client.get_mcp_tool( + name="Microsoft_Learn_MCP", + url="https://learn.microsoft.com/api/mcp", + ) + # Create an Agent using the Azure OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP agent = Agent( - client=FoundryChatClient(credential=AzureCliCredential()), + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=mcp_tool, + tools=[mcp_tool], ) # Run the agent as a hosted agent from_agent_framework(agent).run() diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt index d05845588a..250c059d77 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt @@ -1,2 +1,2 @@ -azure-ai-agentserver-agentframework==1.0.0b3 -agent-framework \ No newline at end of file +azure-ai-agentserver-agentframework==1.0.0b16 +agent-framework diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample index 7a7d4d5ec3..67bcea72f3 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample @@ -1,3 +1,3 @@ # IMPORTANT: Never commit .env to version control - add it to .gitignore -PROJECT_ENDPOINT= -MODEL_DEPLOYMENT_NAME= \ No newline at end of file +FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_MODEL= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md index 41fa3660fa..50efdcb7a4 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md @@ -59,24 +59,24 @@ Before running this sample, ensure you have: Set the following environment variables (matching `agent.yaml`): -- `PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) +- `FOUNDRY_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) +- `FOUNDRY_MODEL` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) This sample loads environment variables from a local `.env` file if present. Create a `.env` file in this directory with the following content: ``` -PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4.1-mini ``` Or set them via PowerShell: ```powershell # Replace with your actual values -$env:PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:FOUNDRY_MODEL="gpt-4.1-mini" ``` ### Running the Sample diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml index 159b31996d..d18fafc374 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml @@ -21,7 +21,7 @@ protocols: - protocol: responses version: v1 environment_variables: - - name: PROJECT_ENDPOINT - value: ${PROJECT_ENDPOINT} - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME} \ No newline at end of file + - name: FOUNDRY_PROJECT_ENDPOINT + value: ${FOUNDRY_PROJECT_ENDPOINT} + - name: FOUNDRY_MODEL + value: ${FOUNDRY_MODEL} \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py index 880162586b..3bdecb5100 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py @@ -18,10 +18,8 @@ from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential # Configure these for your Foundry project # Read the explicit variables present in the .env file -PROJECT_ENDPOINT = os.getenv("PROJECT_ENDPOINT") # e.g., "https://.services.ai.azure.com" -MODEL_DEPLOYMENT_NAME = os.getenv( - "MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini" -) # Your model deployment name e.g., "gpt-4.1-mini" +FOUNDRY_PROJECT_ENDPOINT = os.getenv("FOUNDRY_PROJECT_ENDPOINT") # e.g., "https://.services.ai.azure.com" +FOUNDRY_MODEL = os.getenv("FOUNDRY_MODEL", "gpt-4.1-mini") # Your model deployment name e.g., "gpt-4.1-mini" # Simulated hotel data for Seattle @@ -116,8 +114,8 @@ async def main(): """Main function to run the agent as a web server.""" async with get_credential() as credential: client = FoundryChatClient( - project_endpoint=PROJECT_ENDPOINT, - model=MODEL_DEPLOYMENT_NAME, + project_endpoint=FOUNDRY_PROJECT_ENDPOINT, + model=FOUNDRY_MODEL, credential=credential, ) agent = Agent( diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml index 1e23818b0f..13938f6a51 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml @@ -25,7 +25,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + - name: AZURE_OPENAI_MODEL value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py index ef91d227f4..d72d041f0b 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py @@ -5,7 +5,7 @@ import sys from dataclasses import dataclass from typing import Any -from agent_framework import Agent, AgentSession, BaseContextProvider, Message, SessionContext +from agent_framework import Agent, AgentSession, ContextProvider, Message, SessionContext from agent_framework.foundry import FoundryChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] from azure.identity import DefaultAzureCredential @@ -28,7 +28,7 @@ class TextSearchResult: text: str -class TextSearchContextProvider(BaseContextProvider): +class TextSearchContextProvider(ContextProvider): """A simple context provider that simulates text search results based on keywords in the user's message.""" def __init__(self): @@ -99,7 +99,7 @@ class TextSearchContextProvider(BaseContextProvider): context.extend_messages( self.source_id, - [Message(role="user", text="\n\n".join(json.dumps(result.__dict__, indent=2) for result in results))], + [Message(role="user", contents=["\n\n".join(json.dumps(result.__dict__, indent=2) for result in results)])], ) diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml index 584b462a40..82f4d9e887 100644 --- a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml @@ -20,7 +20,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + - name: AZURE_OPENAI_MODEL value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample index 7a7d4d5ec3..67bcea72f3 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample @@ -1,3 +1,3 @@ # IMPORTANT: Never commit .env to version control - add it to .gitignore -PROJECT_ENDPOINT= -MODEL_DEPLOYMENT_NAME= \ No newline at end of file +FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_MODEL= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md index f4181f6e6b..8a9464f7b5 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md @@ -56,24 +56,24 @@ Before running this sample, ensure you have: Set the following environment variables (matching `agent.yaml`): -- `PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) +- `FOUNDRY_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) +- `FOUNDRY_MODEL` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) This sample loads environment variables from a local `.env` file if present. Create a `.env` file in this directory with the following content: ``` -PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4.1-mini ``` Or set them via PowerShell: ```powershell # Replace with your actual values -$env:PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:FOUNDRY_MODEL="gpt-4.1-mini" ``` ### Running the Sample diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml index 9a5e63b83f..36e08b7e77 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml @@ -18,7 +18,7 @@ protocols: - protocol: responses version: v1 environment_variables: - - name: PROJECT_ENDPOINT - value: ${PROJECT_ENDPOINT} - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME} \ No newline at end of file + - name: FOUNDRY_PROJECT_ENDPOINT + value: ${FOUNDRY_PROJECT_ENDPOINT} + - name: FOUNDRY_MODEL + value: ${FOUNDRY_MODEL} \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py index 757bad4f26..ff42f7d6dc 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py @@ -14,12 +14,10 @@ load_dotenv(override=True) # Configure these for your Foundry project # Read the explicit variables present in the .env file -PROJECT_ENDPOINT = os.getenv( - "PROJECT_ENDPOINT" +FOUNDRY_PROJECT_ENDPOINT = os.getenv( + "FOUNDRY_PROJECT_ENDPOINT" ) # e.g., "https://.services.ai.azure.com/api/projects/" -MODEL_DEPLOYMENT_NAME = os.getenv( - "MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini" -) # Your model deployment name e.g., "gpt-4.1-mini" +FOUNDRY_MODEL = os.getenv("FOUNDRY_MODEL", "gpt-4.1-mini") # Your model deployment name e.g., "gpt-4.1-mini" def get_credential(): @@ -31,8 +29,8 @@ def get_credential(): async def create_agents(): async with get_credential() as credential: client = FoundryChatClient( - project_endpoint=PROJECT_ENDPOINT, - model=MODEL_DEPLOYMENT_NAME, + project_endpoint=FOUNDRY_PROJECT_ENDPOINT, + model=FOUNDRY_MODEL, credential=credential, ) writer = Agent( @@ -60,8 +58,8 @@ async def main() -> None: The writer and reviewer multi-agent workflow. Environment variables required: - - PROJECT_ENDPOINT: Your Microsoft Foundry project endpoint - - MODEL_DEPLOYMENT_NAME: Your Microsoft Foundry model deployment name + - FOUNDRY_PROJECT_ENDPOINT: Your Microsoft Foundry project endpoint + - FOUNDRY_MODEL: Your Microsoft Foundry model deployment name """ async with create_agents() as (writer, reviewer): diff --git a/python/samples/05-end-to-end/m365-agent/.env.example b/python/samples/05-end-to-end/m365-agent/.env.example index 100c2bf69d..82a7bb4575 100644 --- a/python/samples/05-end-to-end/m365-agent/.env.example +++ b/python/samples/05-end-to-end/m365-agent/.env.example @@ -1,6 +1,6 @@ # OpenAI Configuration OPENAI_API_KEY= -OPENAI_CHAT_MODEL= +OPENAI_CHAT_COMPLETION_MODEL= # Agent 365 Agentic Authentication Configuration USE_ANONYMOUS_MODE= diff --git a/python/samples/05-end-to-end/m365-agent/README.md b/python/samples/05-end-to-end/m365-agent/README.md index 6962a53229..ec9da47331 100644 --- a/python/samples/05-end-to-end/m365-agent/README.md +++ b/python/samples/05-end-to-end/m365-agent/README.md @@ -7,7 +7,7 @@ This sample demonstrates a simple Weather Forecast Agent built with the Python M - Python 3.11+ - [uv](https://github.com/astral-sh/uv) for fast dependency management - [devtunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows) -- [Microsoft 365 Agents Toolkit](https://github.com/OfficeDev/microsoft-365-agents-toolkit) for playground/testing +- `agentsplayground` for playground/testing - Access to OpenAI or Azure OpenAI with a model like `gpt-4o-mini` ## Configuration @@ -21,7 +21,7 @@ export USE_ANONYMOUS_MODE=True # set to false if using auth # OpenAI export OPENAI_API_KEY="..." -export OPENAI_CHAT_MODEL="..." +export OPENAI_CHAT_COMPLETION_MODEL="..." ``` ## Installing Dependencies diff --git a/python/samples/05-end-to-end/neo4j_graphrag/README.md b/python/samples/05-end-to-end/neo4j_graphrag/README.md new file mode 100644 index 0000000000..ab7f5e590e --- /dev/null +++ b/python/samples/05-end-to-end/neo4j_graphrag/README.md @@ -0,0 +1,43 @@ +# Neo4j GraphRAG Context Provider + +The [Neo4j GraphRAG context provider](https://github.com/neo4j-labs/neo4j-maf-provider) adds read-only retrieval from a Neo4j knowledge graph to an Agent Framework agent. It supports vector, fulltext, and hybrid retrieval, and can enrich search results by traversing graph relationships with a Cypher `retrieval_query`. + +This sample keeps setup lightweight by using a pre-built Neo4j fulltext index plus a graph-enrichment query. + +## Example + +| File | Description | +|---|---| +| [`main.py`](main.py) | Runnable GraphRAG sample using a Neo4j fulltext index and a Cypher enrichment query to surface related companies, products, and risk factors. | + +## Prerequisites + +1. A Neo4j database with document chunks already loaded +2. A Neo4j fulltext index over chunk text, such as `search_chunks` +3. An Azure AI Foundry project endpoint and chat deployment +4. Azure CLI authentication via `az login` + +## Environment variables + +This sample expects: + +- `FOUNDRY_PROJECT_ENDPOINT` +- `FOUNDRY_MODEL` +- `NEO4J_URI` +- `NEO4J_USERNAME` +- `NEO4J_PASSWORD` +- `NEO4J_FULLTEXT_INDEX_NAME` (optional, defaults to `search_chunks`) + +## Run with uv + +From the `python/` directory: + +```bash +uv run samples/05-end-to-end/neo4j_graphrag/main.py +``` + +## Notes + +- This sample uses the published `agent-framework-neo4j` package rather than code from this repository. +- The package also supports vector and hybrid retrieval when you configure embeddings and indexes in Neo4j. +- For memory-oriented scenarios, the Neo4j project also maintains companion examples in the external provider repository. diff --git a/python/samples/05-end-to-end/neo4j_graphrag/main.py b/python/samples/05-end-to-end/neo4j_graphrag/main.py new file mode 100644 index 0000000000..51e1154c69 --- /dev/null +++ b/python/samples/05-end-to-end/neo4j_graphrag/main.py @@ -0,0 +1,112 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-neo4j", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_neo4j import Neo4jContextProvider, Neo4jSettings +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +This sample demonstrates how to use the Neo4j GraphRAG context provider with +Agent Framework and Azure AI Foundry. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4o) + NEO4J_URI — Neo4j connection URI + NEO4J_USERNAME — Neo4j username + NEO4J_PASSWORD — Neo4j password + NEO4J_FULLTEXT_INDEX_NAME — Optional fulltext index name (defaults to search_chunks) +""" + +USER_INPUTS = [ + "What products does Microsoft offer?", + "What risks does Apple face?", + "Tell me about NVIDIA's AI business and risk factors.", +] + +# Optional graph-enrichment query: retrieval works without this, but supplying +# a query lets the sample attach related company, product, and risk metadata to +# each retrieved chunk. +RETRIEVAL_QUERY = """ +MATCH (node)-[:FROM_DOCUMENT]->(doc:Document)<-[:FILED]-(company:Company) +OPTIONAL MATCH (company)-[:FACES_RISK]->(risk:RiskFactor) +WITH node, score, company, doc, collect(DISTINCT risk.name)[0..5] AS risks +OPTIONAL MATCH (company)-[:MENTIONS]->(product:Product) +WITH node, score, company, doc, risks, collect(DISTINCT product.name)[0..5] AS products +RETURN + node.text AS text, + score, + company.name AS company, + company.ticker AS ticker, + doc.title AS title, + risks, + products +ORDER BY score DESC +""" + + +async def main() -> None: + # 1. Load and validate the Neo4j connection settings. + settings = Neo4jSettings() + if not settings.is_configured: + raise RuntimeError("Set NEO4J_URI, NEO4J_USERNAME, and NEO4J_PASSWORD before running this sample.") + + # 2. Read the Azure AI Foundry project endpoint and model configuration. + project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") + if not project_endpoint: + raise RuntimeError("Set FOUNDRY_PROJECT_ENDPOINT before running this sample.") + + model = os.environ.get("FOUNDRY_MODEL") or "gpt-4o" + + # 3. Create the Neo4j context provider and Foundry-backed agent, then ask sample questions. + async with ( + AzureCliCredential() as credential, + Neo4jContextProvider( + source_id="neo4j_graphrag", + uri=settings.uri, + username=settings.username, + password=settings.get_password(), + index_name=settings.fulltext_index_name, + index_type="fulltext", + retrieval_query=RETRIEVAL_QUERY, + top_k=5, + ) as provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="Neo4jGraphRAGAgent", + instructions=( + "You are a helpful assistant. Use the Neo4j context provider results to answer accurately. " + "If the retrieved context is insufficient, say so plainly." + ), + context_providers=[provider], + ) as agent, + ): + session = agent.create_session() + print("=== Neo4j GraphRAG Context Provider ===\n") + + for user_input in USER_INPUTS: + print(f"User: {user_input}") + result = await agent.run(user_input, session=session) + print(f"Agent: {getattr(result, 'text', result)}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/05-end-to-end/purview_agent/README.md b/python/samples/05-end-to-end/purview_agent/README.md index 3d13478616..1cdb7e3ef4 100644 --- a/python/samples/05-end-to-end/purview_agent/README.md +++ b/python/samples/05-end-to-end/purview_agent/README.md @@ -18,7 +18,7 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat | Variable | Required | Purpose | |----------|----------|---------| | `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint (https://.openai.azure.com) | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Optional | Model deployment name (defaults inside SDK if omitted) | +| `AZURE_OPENAI_MODEL` | Optional | Model deployment name (defaults inside SDK if omitted) | | `PURVIEW_CLIENT_APP_ID` | Yes* | Client (application) ID used for Purview authentication | | `PURVIEW_USE_CERT_AUTH` | Optional (`true`/`false`) | Switch between certificate and interactive auth | | `PURVIEW_TENANT_ID` | Yes (when cert auth on) | Tenant ID for certificate authentication | diff --git a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py index 369e39655b..5eb2845886 100644 --- a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py +++ b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py @@ -12,7 +12,7 @@ Note: Caching is automatic and enabled by default. Environment variables: - AZURE_OPENAI_ENDPOINT (required) -- AZURE_OPENAI_DEPLOYMENT_NAME (optional, defaults to gpt-4o-mini) +- AZURE_OPENAI_MODEL (optional, defaults to gpt-4o-mini) - PURVIEW_CLIENT_APP_ID (required) - PURVIEW_USE_CERT_AUTH (optional, set to "true" for certificate auth) - PURVIEW_TENANT_ID (required if certificate auth) @@ -143,7 +143,7 @@ async def run_with_agent_middleware() -> None: print("Skipping run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) @@ -168,7 +168,9 @@ async def run_with_agent_middleware() -> None: print("First response (agent middleware):\n", first) second: AgentResponse = await agent.run( - Message(role="user", text="That was funny. Tell me another one.", additional_properties={"user_id": user_id}) + Message( + role="user", contents=["That was funny. Tell me another one."], additional_properties={"user_id": user_id} + ) ) print("Second response (agent middleware):\n", second) @@ -179,7 +181,7 @@ async def run_with_chat_middleware() -> None: print("Skipping chat middleware run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", default="gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", default="gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient( @@ -206,7 +208,7 @@ async def run_with_chat_middleware() -> None: first: AgentResponse = await agent.run( Message( role="user", - text="Give me a short clean joke.", + contents=["Give me a short clean joke."], additional_properties={"user_id": user_id}, ) ) @@ -215,7 +217,7 @@ async def run_with_chat_middleware() -> None: second: AgentResponse = await agent.run( Message( role="user", - text="One more please.", + contents=["One more please."], additional_properties={"user_id": user_id}, ) ) @@ -229,7 +231,7 @@ async def run_with_custom_cache_provider() -> None: print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) @@ -254,7 +256,9 @@ async def run_with_custom_cache_provider() -> None: print("Using SimpleDictCacheProvider") first: AgentResponse = await agent.run( - Message(role="user", text="Tell me a joke about a programmer.", additional_properties={"user_id": user_id}) + Message( + role="user", contents=["Tell me a joke about a programmer."], additional_properties={"user_id": user_id} + ) ) print("First response (custom provider):\n", first) @@ -269,7 +273,7 @@ async def run_with_custom_cache_provider() -> None: print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) diff --git a/python/samples/05-end-to-end/workflow_evaluation/.env.example b/python/samples/05-end-to-end/workflow_evaluation/.env.example index b7a06ab22a..57f0415e8c 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/.env.example +++ b/python/samples/05-end-to-end/workflow_evaluation/.env.example @@ -1,3 +1,3 @@ -AZURE_AI_PROJECT_ENDPOINT="" -AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW="" -AZURE_AI_MODEL_DEPLOYMENT_NAME_EVAL="" +FOUNDRY_PROJECT_ENDPOINT="" +FOUNDRY_MODEL_WORKFLOW="" +FOUNDRY_MODEL_EVAL="" diff --git a/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py index f0dbb504cc..0be2478b3e 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py +++ b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py @@ -106,11 +106,15 @@ class ResearchLead(Executor): messages = [ Message( role="system", - text="You are a travel planning coordinator. Summarize findings from multiple specialized travel agents and provide a clear, comprehensive travel plan based on the user's query.", + contents=[ + "You are a travel planning coordinator. Summarize findings from multiple specialized travel agents and provide a clear, comprehensive travel plan based on the user's query." + ], ), Message( role="user", - text=f"Original query: {user_query}\n\nFindings from specialized travel agents:\n{summary_text}\n\nPlease provide a comprehensive travel plan based on these findings.", + contents=[ + f"Original query: {user_query}\n\nFindings from specialized travel agents:\n{summary_text}\n\nPlease provide a comprehensive travel plan based on these findings." + ], ), ] @@ -145,14 +149,14 @@ class ResearchLead(Executor): async def run_workflow_with_response_tracking( - query: str, client: FoundryChatClient | None = None, deployment_name: str | None = None + query: str, client: FoundryChatClient | None = None, model: str | None = None ) -> dict: """Run multi-agent workflow and track conversation IDs, response IDs, and interaction sequence. Args: query: The user query to process through the multi-agent workflow client: Optional FoundryChatClient instance - deployment_name: Optional model deployment name for the workflow agents + model: Optional model for the workflow agents Returns: Dictionary containing interaction sequence, conversation/response IDs, and conversation analysis @@ -166,7 +170,7 @@ async def run_workflow_with_response_tracking( ) async with project_client: - client = FoundryChatClient(project_client=project_client, model=deployment_name) + client = FoundryChatClient(project_client=project_client, model=model) return await _run_workflow_with_client(query, client) except Exception as e: print(f"Error during workflow execution: {e}") @@ -347,11 +351,11 @@ def _track_agent_ids(event, agent, response_ids, conversation_ids): conversation_ids[agent].append(raw.conversation_id) -async def create_and_run_workflow(deployment_name: str | None = None): +async def create_and_run_workflow(model: str | None = None): """Run the workflow evaluation and display results. Args: - deployment_name: Optional model deployment name for the workflow agents + model: Optional model for the workflow agents Returns: Dictionary containing agents data with conversation IDs, response IDs, and query information @@ -365,7 +369,7 @@ async def create_and_run_workflow(deployment_name: str | None = None): query = example_queries[0] print(f"Query: {query}\n") - result = await run_workflow_with_response_tracking(query, model=deployment_name) + result = await run_workflow_with_response_tracking(query, model=model) # Create output data structure output_data = {"agents": {}, "query": result["query"], "output": result.get("output", "")} diff --git a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py index 2ac8b90cb7..e246710b41 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py +++ b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py @@ -46,11 +46,11 @@ def print_section(title: str): print(f"{'=' * 80}") -async def run_workflow(deployment_name: str | None = None) -> dict[str, Any]: +async def run_workflow(model: str | None = None) -> dict[str, Any]: """Execute the multi-agent travel planning workflow. Args: - deployment_name: Optional model deployment name for the workflow agents + model: Optional model for the workflow agents Returns: Dictionary containing workflow data with agent response IDs @@ -58,7 +58,7 @@ async def run_workflow(deployment_name: str | None = None) -> dict[str, Any]: print("Executing multi-agent travel planning workflow...") print("This may take a few minutes...") - workflow_data = await create_and_run_workflow(model=deployment_name) + workflow_data = await create_and_run_workflow(model=model) print("Workflow execution completed") return workflow_data @@ -97,9 +97,9 @@ def fetch_agent_responses(openai_client: OpenAI, workflow_data: dict[str, Any], print(f" Error: {e}") -def create_evaluation(openai_client: OpenAI, deployment_name: str | None = "gpt-5.2") -> EvalCreateResponse: +def create_evaluation(openai_client: OpenAI, model: str | None = "gpt-5.2") -> EvalCreateResponse: """Create evaluation with multiple evaluators.""" - deployment_name = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", deployment_name) + model = os.environ.get("FOUNDRY_MODEL", model) data_source_config = {"type": "azure_ai_source", "scenario": "responses"} testing_criteria = [ @@ -107,25 +107,25 @@ def create_evaluation(openai_client: OpenAI, deployment_name: str | None = "gpt- "type": "azure_ai_evaluator", "name": "relevance", "evaluator_name": "builtin.relevance", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, { "type": "azure_ai_evaluator", "name": "groundedness", "evaluator_name": "builtin.groundedness", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, { "type": "azure_ai_evaluator", "name": "tool_call_accuracy", "evaluator_name": "builtin.tool_call_accuracy", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, { "type": "azure_ai_evaluator", "name": "tool_output_utilization", "evaluator_name": "builtin.tool_output_utilization", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, ] @@ -199,8 +199,8 @@ async def main(): openai_client = create_openai_client() # Model configuration - workflow_agent_model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW", "gpt-4.1-nano") - eval_model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME_EVAL", "gpt-5.2") + workflow_agent_model = os.environ.get("FOUNDRY_MODEL_WORKFLOW", "gpt-4.1-nano") + eval_model = os.environ.get("FOUNDRY_MODEL_EVAL", "gpt-5.2") # Focus on these agents, uncomment other ones you want to have evals run on agents_to_evaluate = [ diff --git a/python/samples/AGENTS.md b/python/samples/AGENTS.md index 09674da7d9..cf162f3e7d 100644 --- a/python/samples/AGENTS.md +++ b/python/samples/AGENTS.md @@ -66,26 +66,26 @@ python/samples/ ## Default provider -All canonical samples (01-get-started) use **Azure OpenAI Responses** via `AzureOpenAIResponsesClient` +All canonical samples (01-get-started) use **Azure AI Foundry project-backed chat** via `FoundryChatClient` with an Azure AI Foundry project endpoint: ```python import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential credential = AzureCliCredential() -client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=credential, ) agent = client.as_agent(name="...", instructions="...") ``` Environment variables: -- `AZURE_AI_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` — Model deployment name (e.g. gpt-4o) +- `FOUNDRY_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL` — Model deployment name (e.g. gpt-4o) For authentication, run `az login` before running samples. @@ -102,10 +102,10 @@ code here ## Package install ```bash -pip install agent-framework --pre +pip install agent-framework ``` -The `--pre` flag is needed during preview. `openai` is a core dependency. +`agent-framework` is released, so `--pre` is not required here. `openai` is a core dependency. ## Current API notes diff --git a/python/samples/README.md b/python/samples/README.md index 33a385a4f9..953d9ff9bb 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -26,7 +26,7 @@ Start with `01-get-started/` and work through the numbered files: ## Prerequisites ```bash -pip install agent-framework --pre +pip install agent-framework ``` ### Environment Variables @@ -50,7 +50,7 @@ export FOUNDRY_MODEL="gpt-4o" **Option 3: Using `env_file_path` parameter** (for per-client configuration): -All client classes (e.g., `OpenAIChatClient`, `AzureOpenAIResponsesClient`) support an `env_file_path` parameter to load environment variables from a specific file: +All client classes (e.g., `OpenAIChatClient`, `OpenAIChatCompletionClient`) support an `env_file_path` parameter to load environment variables from a specific file: ```python from agent_framework.openai import OpenAIChatClient @@ -77,6 +77,92 @@ FOUNDRY_PROJECT_ENDPOINT="your-foundry-project-endpoint" FOUNDRY_MODEL="gpt-4o" ``` +#### Consolidated sample env inventory + +This is the single source of truth for package-level environment variables read by packages included by +`agent-framework-core[all]`. It intentionally excludes variables that are only read by standalone samples, +package sample folders, or tests. When package code adds, removes, or renames an environment variable, +update this table in the same change. + +Example values below are illustrative. For entries not backed by a single public class, the `class` +column names the closest public surface, helper, or package-level initialization point that reads the +variable. + +| package | class | env var | example value | +| --- | --- | --- | --- | +| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_API_KEY` | `sk-ant-api03-...` | +| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL` | `claude-sonnet-4-5-20250929` | +| `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_MODELS_ENDPOINT` | `https://my-endpoint.inference.ai.azure.com` | +| `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_MODELS_API_KEY` | `env-key` | +| `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_EMBEDDING_MODEL` | `text-embedding-3-small` | +| `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_IMAGE_EMBEDDING_MODEL` | `Cohere-embed-v3-english` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_ENDPOINT` | `https://my-search.search.windows.net` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_API_KEY` | `search-key` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_INDEX_NAME` | `hotels-index` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_KNOWLEDGE_BASE_NAME` | `hotels-kb` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_ENDPOINT` | `https://my-cosmos.documents.azure.com:443/` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_DATABASE_NAME` | `agent-history` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_CONTAINER_NAME` | `messages` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_KEY` | `C2F...==` | +| `agent-framework-bedrock` | `BedrockChatClient` | `BEDROCK_REGION` | `us-east-1` | +| `agent-framework-bedrock` | `BedrockChatClient` | `BEDROCK_CHAT_MODEL` | `anthropic.claude-3-5-sonnet-20241022-v2:0` | +| `agent-framework-bedrock` | `BedrockEmbeddingClient` | `BEDROCK_REGION` | `us-east-1` | +| `agent-framework-bedrock` | `BedrockEmbeddingClient` | `BEDROCK_EMBEDDING_MODEL` | `amazon.titan-embed-text-v2:0` | +| `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_ACCESS_KEY_ID` | `AKIAIOSFODNN7EXAMPLE` | +| `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_SECRET_ACCESS_KEY` | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | +| `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_SESSION_TOKEN` | `IQoJb3JpZ2luX2VjEO7//////////wEaCXVzLXdlc3QtMiJHMEUCIQD...` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__ENVIRONMENTID` | `00000000-0000-0000-0000-000000000000` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__SCHEMANAME` | `cr123_agentname` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__TENANTID` | `11111111-1111-1111-1111-111111111111` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__AGENTAPPID` | `22222222-2222-2222-2222-222222222222` | +| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_INSTRUMENTATION` | `true` | +| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_SENSITIVE_DATA` | `false` | +| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_CONSOLE_EXPORTERS` | `true` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_NAME` | `sample-agent` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_VERSION` | `1.0.0` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` | +| `agent-framework-devui` | `DevUI server` | `DEVUI_AUTH_TOKEN` | `my-devui-token` | +| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_PROJECT_ENDPOINT` | `https://my-project.services.ai.azure.com/api/projects/my-project` | +| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_MODEL` | `gpt-4o` | +| `agent-framework-foundry` | `FoundryAgent` | `FOUNDRY_AGENT_NAME` | `travel-planner` | +| `agent-framework-foundry` | `FoundryAgent` | `FOUNDRY_AGENT_VERSION` | `v1` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_CLI_PATH` | `copilot` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_MODEL` | `gpt-5` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_TIMEOUT` | `60` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_LOG_LEVEL` | `info` | +| `agent-framework-mem0` | `agent_framework_mem0 package import` | `MEM0_TELEMETRY` | `false` | +| `agent-framework-ollama` | `OllamaChatClient` | `OLLAMA_HOST` | `http://localhost:11434` | +| `agent-framework-ollama` | `OllamaChatClient` | `OLLAMA_MODEL` | `llama3.1:8b` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_API_KEY` | `sk-proj-...` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_MODEL` | `gpt-4o-mini` | +| `agent-framework-openai` | `OpenAIChatClient` | `OPENAI_CHAT_MODEL` | `gpt-4.1-mini` | +| `agent-framework-openai` | `OpenAIChatCompletionClient` | `OPENAI_CHAT_COMPLETION_MODEL` | `gpt-4o` | +| `agent-framework-openai` | `OpenAIEmbeddingClient` | `OPENAI_EMBEDDING_MODEL` | `text-embedding-3-small` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_BASE_URL` | `https://api.openai.com/v1/` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_ORG_ID` | `org_123456789` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_ENDPOINT` | `https://my-resource.openai.azure.com/` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_API_KEY` | `sk-azure-...` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_API_VERSION` | `2024-10-21` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_BASE_URL` | `https://my-resource.openai.azure.com/openai/v1/` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_MODEL` | `gpt-4o` | +| `agent-framework-openai` | `OpenAIChatClient` | `AZURE_OPENAI_CHAT_MODEL` | `gpt-4.1` | +| `agent-framework-openai` | `OpenAIChatCompletionClient` | `AZURE_OPENAI_CHAT_COMPLETION_MODEL` | `gpt-4o-mini` | +| `agent-framework-openai` | `OpenAIEmbeddingClient` | `AZURE_OPENAI_EMBEDDING_MODEL` | `text-embedding-3-large` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_RESOURCE_URL` | `https://cognitiveservices.azure.com/` | + +`agent-framework-openai` supports the Azure OpenAI client-specific deployment aliases listed above; keep +`packages/openai/README.md` as the authoritative reference for the exact fallback order and package-specific +behavior. + **Note for production**: In production environments, set environment variables through your deployment platform (e.g., Azure App Settings, Kubernetes ConfigMaps/Secrets) rather than using `.env` files. The `load_dotenv()` call in samples will have no effect when a `.env` file is not present, allowing environment variables to be loaded from the system. For Azure authentication, run `az login` before running samples. diff --git a/python/samples/autogen-migration/README.md b/python/samples/autogen-migration/README.md index 2bfa229183..30f03e3fea 100644 --- a/python/samples/autogen-migration/README.md +++ b/python/samples/autogen-migration/README.md @@ -6,9 +6,9 @@ This gallery helps AutoGen developers move to the Microsoft Agent Framework (AF) ### Single-Agent Parity -- [01_basic_assistant_agent.py](single_agent/01_basic_assistant_agent.py) — Minimal AutoGen `AssistantAgent` and AF `Agent` comparison. -- [02_assistant_agent_with_tool.py](single_agent/02_assistant_agent_with_tool.py) — Function tool integration in both SDKs. -- [03_assistant_agent_thread_and_stream.py](single_agent/03_assistant_agent_thread_and_stream.py) — Session management and streaming responses. +- [01_basic_agent.py](single_agent/01_basic_agent.py) — Minimal AutoGen `AssistantAgent` and AF `Agent` comparison. +- [02_agent_with_tool.py](single_agent/02_agent_with_tool.py) — Function tool integration in both SDKs. +- [03_agent_thread_and_stream.py](single_agent/03_agent_thread_and_stream.py) — Session management and streaming responses. - [04_agent_as_tool.py](single_agent/04_agent_as_tool.py) — Using agents as tools (hierarchical agent pattern) and streaming with tools. ### Multi-Agent Orchestration @@ -35,7 +35,7 @@ Each script is fully async and the `main()` routine runs both implementations ba From the repository root: ```bash -python samples/autogen-migration/single_agent/01_basic_assistant_agent.py +python samples/autogen-migration/single_agent/01_basic_agent.py ``` Every script accepts no CLI arguments and will first call the AutoGen implementation, followed by the AF version. Adjust the prompt or credentials inside the file as necessary before running. diff --git a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py index ae78061260..ec2a2adb60 100644 --- a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py +++ b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py @@ -65,7 +65,7 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import SequentialBuilder - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized agents researcher = Agent( @@ -112,7 +112,7 @@ async def run_agent_framework_with_cycle() -> None: ) from agent_framework.openai import OpenAIChatClient - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized agents researcher = Agent( diff --git a/python/samples/autogen-migration/orchestrations/04_magentic_one.py b/python/samples/autogen-migration/orchestrations/04_magentic_one.py index e10636b10f..9a7dec30ee 100644 --- a/python/samples/autogen-migration/orchestrations/04_magentic_one.py +++ b/python/samples/autogen-migration/orchestrations/04_magentic_one.py @@ -76,7 +76,7 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import MagenticBuilder - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized agents researcher = Agent( diff --git a/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py b/python/samples/autogen-migration/single_agent/01_basic_agent.py similarity index 100% rename from python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py rename to python/samples/autogen-migration/single_agent/01_basic_agent.py diff --git a/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py b/python/samples/autogen-migration/single_agent/02_agent_with_tool.py similarity index 100% rename from python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py rename to python/samples/autogen-migration/single_agent/02_agent_with_tool.py diff --git a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py b/python/samples/autogen-migration/single_agent/03_agent_thread_and_stream.py similarity index 100% rename from python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py rename to python/samples/autogen-migration/single_agent/03_agent_thread_and_stream.py diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md index f7da9de9c5..f971c4c34c 100644 --- a/python/samples/semantic-kernel-migration/README.md +++ b/python/samples/semantic-kernel-migration/README.md @@ -14,9 +14,9 @@ This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent F ### Azure AI agent parity ### OpenAI Assistants API parity -- [01_basic_openai_assistant.py](openai_assistant/01_basic_openai_assistant.py) — Baseline assistant comparison. -- [02_openai_assistant_with_code_interpreter.py](openai_assistant/02_openai_assistant_with_code_interpreter.py) — Code interpreter tool usage. -- [03_openai_assistant_function_tool.py](openai_assistant/03_openai_assistant_function_tool.py) — Custom function tooling. + +OpenAI Assistants parity samples were removed alongside the deprecated Python assistants surface and are no longer +part of this migration gallery. ### OpenAI Responses API parity - [01_basic_responses_agent.py](openai_responses/01_basic_responses_agent.py) — Basic responses agent migration. @@ -44,7 +44,7 @@ Each script is fully async and the `main()` routine runs both implementations ba - Python 3.10 or later. - Access to the necessary model endpoints (Azure OpenAI, OpenAI, Azure AI, Copilot Studio, etc.). - Installed SDKs: `semantic-kernel` and the Microsoft Agent Framework (`pip install semantic-kernel agent-framework`), or the repo’s editable packages if you are developing locally. -- Service credentials exposed through environment variables (for example `OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_KEY`, or Copilot Studio auth settings). +- Service credentials exposed through environment variables (for example `OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, or Copilot Studio auth settings). ## Running Single-Agent Samples From the repository root: diff --git a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py index d84e560eb0..d5b1203518 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py +++ b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py @@ -23,7 +23,7 @@ load_dotenv() async def run_semantic_kernel() -> None: - from semantic_kernel.agents import ChatCompletionAgent, ChatHistoryAgentThread + from semantic_kernel.agents import ChatCompletionAgent from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion from semantic_kernel.functions import kernel_function @@ -39,11 +39,7 @@ async def run_semantic_kernel() -> None: instructions="Answer menu questions accurately.", plugins=[SpecialsPlugin()], ) - thread = ChatHistoryAgentThread() - response = await agent.get_response( - messages="What soup can I order today?", - thread=thread, - ) + response = await agent.get_response("What soup can I order today?") print("[SK]", response.message.content) @@ -62,12 +58,7 @@ async def run_agent_framework() -> None: instructions="Answer menu questions accurately.", tools=[specials], ) - session = chat_agent.create_session() - reply = await chat_agent.run( - "What soup can I order today?", - session=session, - tool_choice="auto", - ) + reply = await chat_agent.run("What soup can I order today?") print("[AF]", reply.text) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py b/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py deleted file mode 100644 index f171d076bd..0000000000 --- a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py +++ /dev/null @@ -1,64 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py - -# Copyright (c) Microsoft. All rights reserved. -"""Create an OpenAI Assistant using SK and Agent Framework.""" -import asyncio -import os - -from agent_framework import Agent -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() -ASSISTANT_MODEL = os.environ.get("OPENAI_ASSISTANT_MODEL", "gpt-4o-mini") - - -async def run_semantic_kernel() -> None: - from semantic_kernel.agents import AssistantAgentThread, OpenAIAssistantAgent - client = OpenAIAssistantAgent.create_client() - # Provision the assistant on the OpenAI Assistants service. - definition = await client.beta.assistants.create( - model=ASSISTANT_MODEL, - name="Helper", - instructions="Answer questions in one concise paragraph.", - ) - agent = OpenAIAssistantAgent(client=client, definition=definition) - thread: AssistantAgentThread | None = None - response = await agent.get_response("What is the capital of Denmark?", thread=thread) - thread = response.thread - print("[SK]", response.message.content) - if thread is not None: - print("[SK][thread-id]", thread.id) - - -async def run_agent_framework() -> None: - from agent_framework.openai import OpenAIAssistantsClient - assistants_client = OpenAIAssistantsClient() - # AF wraps the assistant lifecycle with an async context manager. - async with Agent( - client=assistants_client, - ) as assistant_agent: - session = assistant_agent.create_session() - reply = await assistant_agent.run("What is the capital of Denmark?", session=session) - print("[AF]", reply.text) - follow_up = await assistant_agent.run( - "How many residents live there?", - session=session, - ) - print("[AF][follow-up]", follow_up.text) - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py b/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py deleted file mode 100644 index f968f7851e..0000000000 --- a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py +++ /dev/null @@ -1,74 +0,0 @@ -# /// script - -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py - -# Copyright (c) Microsoft. All rights reserved. -"""Enable the code interpreter tool for OpenAI Assistants in SK and AF.""" - -import asyncio - -from agent_framework import Agent -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -async def run_semantic_kernel() -> None: - from semantic_kernel.agents import OpenAIAssistantAgent - from semantic_kernel.connectors.ai.open_ai import OpenAISettings - - client = OpenAIAssistantAgent.create_client() - - code_interpreter_tool, code_interpreter_tool_resources = OpenAIAssistantAgent.configure_code_interpreter_tool() - - # Enable the hosted code interpreter tool on the assistant definition. - definition = await client.beta.assistants.create( - model=OpenAISettings().chat_model_id, - name="CodeRunner", - instructions="Run the provided request as code and return the result.", - tools=code_interpreter_tool, - tool_resources=code_interpreter_tool_resources, - ) - agent = OpenAIAssistantAgent(client=client, definition=definition) - response = await agent.get_response( - "Use Python to calculate the mean of [41, 42, 45] and explain the steps.", - ) - print(f"[SK]: {response}") - - -async def run_agent_framework() -> None: - from agent_framework.openai import OpenAIAssistantsClient - - assistants_client = OpenAIAssistantsClient() - - # Create code interpreter tool using static method - code_interpreter_tool = OpenAIAssistantsClient.get_code_interpreter_tool() - - # AF exposes the same tool configuration via create_agent. - async with Agent(client=assistants_client, - name="CodeRunner", - instructions="Use the code interpreter when calculations are required.", - model="gpt-4.1", - tools=[code_interpreter_tool], - ) as assistant_agent: - response = await assistant_agent.run( - "Use Python to calculate the mean of [41, 42, 45] and explain the steps.", - tool_choice="auto", - ) - print(f"[AF]: {response.text}") - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py b/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py deleted file mode 100644 index 36d6fea208..0000000000 --- a/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py +++ /dev/null @@ -1,103 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py - -# Copyright (c) Microsoft. All rights reserved. -"""Implement a function tool for OpenAI Assistants in SK and AF.""" - -import asyncio -import os -from typing import Any - -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -ASSISTANT_MODEL = os.environ.get("OPENAI_ASSISTANT_MODEL", "gpt-4o-mini") - - -async def fake_weather_lookup(city: str, day: str) -> dict[str, Any]: - """Pretend to call a weather service.""" - - return { - "city": city, - "day": day, - "forecast": "Sunny with scattered clouds", - "high_c": 22, - "low_c": 14, - } - - -async def run_semantic_kernel() -> None: - from semantic_kernel.agents import AssistantAgentThread, OpenAIAssistantAgent - from semantic_kernel.functions import kernel_function - - class WeatherPlugin: - @kernel_function(name="get_forecast", description="Look up the forecast for a city and day.") - async def fake_weather_lookup(self, city: str, day: str) -> dict[str, Any]: - """Pretend to call a weather service.""" - return { - "city": city, - "day": day, - "forecast": "Sunny with scattered clouds", - "high_c": 22, - "low_c": 14, - } - - client = OpenAIAssistantAgent.create_client() - # Tool schema is registered on the assistant definition. - definition = await client.beta.assistants.create( - model=ASSISTANT_MODEL, - name="WeatherHelper", - instructions="Call get_forecast to fetch weather details.", - ) - agent = OpenAIAssistantAgent(client=client, definition=definition, plugins=[WeatherPlugin()]) - - thread: AssistantAgentThread | None = None - response = await agent.get_response( - "What will the weather be like in Seattle tomorrow?", - thread=thread, - ) - thread = response.thread - print("[SK][initial]", response.message.content) - - -async def run_agent_framework() -> None: - from agent_framework import Agent, tool - from agent_framework.openai import OpenAIAssistantsClient - - @tool( - name="get_forecast", - description="Look up the forecast for a city and day.", - ) - async def get_forecast(city: str, day: str) -> dict[str, Any]: - return await fake_weather_lookup(city, day) - - assistants_client = OpenAIAssistantsClient() - # AF converts the decorated function into an assistant-compatible tool. - async with Agent(client=assistants_client, - name="WeatherHelper", - instructions="Call get_forecast to fetch weather details.", - model=ASSISTANT_MODEL, - tools=[get_forecast], - ) as assistant_agent: - reply = await assistant_agent.run( - "What will the weather be like in Seattle tomorrow?", - tool_choice="auto", - ) - print("[AF]", reply.text) - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py index 54994d7f1f..d74487d1e8 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py +++ b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py @@ -22,10 +22,13 @@ async def run_semantic_kernel() -> None: from semantic_kernel.agents import OpenAIResponsesAgent from semantic_kernel.connectors.ai.open_ai import OpenAISettings + openai_settings = OpenAISettings() + assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + client = OpenAIResponsesAgent.create_client() # SK response agents wrap OpenAI's hosted Responses API. agent = OpenAIResponsesAgent( - ai_model=OpenAISettings().responses_model_id, + ai_model_id=openai_settings.responses_model_id, client=client, instructions="Answer in one concise sentence.", name="Expert", @@ -36,11 +39,11 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework import Agent - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient - # AF Agent can swap in an OpenAIResponsesClient directly. + # AF Agent can swap in an OpenAIChatClient directly. chat_agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="Answer in one concise sentence.", name="Expert", ) diff --git a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py index d2855a7810..01b783aff9 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py +++ b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py @@ -28,10 +28,13 @@ async def run_semantic_kernel() -> None: def add(self, a: float, b: float) -> float: return a + b + openai_settings = OpenAISettings() + assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + client = OpenAIResponsesAgent.create_client() # Plugins advertise callable tools to the Responses agent. agent = OpenAIResponsesAgent( - ai_model=OpenAISettings().responses_model_id, + ai_model_id=openai_settings.responses_model_id, client=client, instructions="Use the add tool when math is required.", name="MathExpert", @@ -43,14 +46,14 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework import Agent, tool - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient @tool(name="add", description="Add two numbers") async def add(a: float, b: float) -> float: return a + b chat_agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="Use the add tool when math is required.", name="MathExpert", # AF registers the async function as a tool at construction. diff --git a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py index a4328ce05f..cbfbf470a0 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py +++ b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py @@ -29,14 +29,17 @@ async def run_semantic_kernel() -> None: from semantic_kernel.agents import OpenAIResponsesAgent from semantic_kernel.connectors.ai.open_ai import OpenAISettings + openai_settings = OpenAISettings() + assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + client = OpenAIResponsesAgent.create_client() # response_format requests schema-constrained output from the model. agent = OpenAIResponsesAgent( - ai_model=OpenAISettings().responses_model_id, + ai_model_id=openai_settings.responses_model_id, client=client, instructions="Return launch briefs as structured JSON.", name="ProductMarketer", - text=OpenAIResponsesAgent.configure_response_format(ReleaseBrief), + text=OpenAIResponsesAgent.configure_response_format(ReleaseBrief), # type: ignore ) response = await agent.get_response( "Draft a launch brief for the Contoso Note app.", @@ -46,10 +49,10 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework import Agent - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient chat_agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="Return launch briefs as structured JSON.", name="ProductMarketer", ) diff --git a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py index ed0a4b1495..11140aa875 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py @@ -16,7 +16,7 @@ from collections.abc import Sequence from typing import cast from agent_framework import Agent, Message -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -55,7 +55,7 @@ def build_semantic_kernel_agents() -> list[ChatCompletionAgent]: async def run_semantic_kernel_example(prompt: str) -> Sequence[ChatMessageContent]: - concurrent_orchestration = ConcurrentOrchestration(members=build_semantic_kernel_agents()) + concurrent_orchestration = ConcurrentOrchestration(members=build_semantic_kernel_agents()) # type: ignore runtime = InProcessRuntime() runtime.start() @@ -89,14 +89,16 @@ def _print_semantic_kernel_outputs(outputs: Sequence[ChatMessageContent]) -> Non async def run_agent_framework_example(prompt: str) -> Sequence[list[Message]]: - client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) - physics = Agent(client=client, + physics = Agent( + client=client, instructions=("You are an expert in physics. Answer questions from a physics perspective."), name="physics", ) - chemistry = Agent(client=client, + chemistry = Agent( + client=client, instructions=("You are an expert in chemistry. Answer questions from a chemistry perspective."), name="chemistry", ) diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py index fa539a98b4..51252a1786 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py +++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py @@ -16,8 +16,8 @@ import sys from collections.abc import Sequence from typing import Any, cast -from agent_framework import Agent, Message -from agent_framework.foundry import FoundryChatClient +from agent_framework import Agent, AgentResponseUpdate, Message +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import GroupChatBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -82,9 +82,6 @@ def build_semantic_kernel_agents() -> list[ChatCompletionAgent]: class ChatCompletionGroupChatManager(GroupChatManager): """Group chat manager that delegates orchestration decisions to an Azure OpenAI deployment.""" - service: ChatCompletionClientBase - topic: str - termination_prompt: str = ( "You are coordinating a conversation about '{{$topic}}'. " "Decide if the discussion has produced a solid answer. " @@ -104,8 +101,11 @@ class ChatCompletionGroupChatManager(GroupChatManager): ) def __init__(self, *, topic: str, service: ChatCompletionClientBase, max_rounds: int | None = None) -> None: - super().__init__(topic=topic, service=service, max_rounds=max_rounds) + super().__init__(max_rounds=max_rounds) + self._round_robin_index = 0 + self._topic = topic + self._service = service async def _render_prompt(self, template: str, **kwargs: Any) -> str: prompt_template = KernelPromptTemplate(prompt_template_config=PromptTemplateConfig(template=template)) @@ -117,7 +117,7 @@ class ChatCompletionGroupChatManager(GroupChatManager): @override async def should_terminate(self, chat_history: ChatHistory) -> BooleanResult: - rendered_prompt = await self._render_prompt(self.termination_prompt, topic=self.topic) + rendered_prompt = await self._render_prompt(self.termination_prompt, topic=self._topic) chat_history.messages.insert( 0, ChatMessageContent(role=AuthorRole.SYSTEM, content=rendered_prompt), @@ -126,11 +126,11 @@ class ChatCompletionGroupChatManager(GroupChatManager): ChatMessageContent(role=AuthorRole.USER, content="Decide if the discussion is complete."), ) - response = await self.service.get_chat_message_content( + response = await self._service.get_chat_message_content( chat_history, settings=PromptExecutionSettings(response_format=BooleanResult), ) - return BooleanResult.model_validate_json(response.content) + return BooleanResult.model_validate_json(response.content) # type: ignore @override async def select_next_agent( @@ -140,7 +140,7 @@ class ChatCompletionGroupChatManager(GroupChatManager): ) -> StringResult: rendered_prompt = await self._render_prompt( self.selection_prompt, - topic=self.topic, + topic=self._topic, participants=", ".join(participant_descriptions.keys()), ) chat_history.messages.insert( @@ -151,18 +151,18 @@ class ChatCompletionGroupChatManager(GroupChatManager): ChatMessageContent(role=AuthorRole.USER, content="Pick the next participant to speak."), ) - response = await self.service.get_chat_message_content( + response = await self._service.get_chat_message_content( chat_history, settings=PromptExecutionSettings(response_format=StringResult), ) - result = StringResult.model_validate_json(response.content) + result = StringResult.model_validate_json(response.content) # type: ignore if result.result not in participant_descriptions: raise RuntimeError(f"Unknown participant selected: {result.result}") return result @override async def filter_results(self, chat_history: ChatHistory) -> MessageResult: - rendered_prompt = await self._render_prompt(self.summary_prompt, topic=self.topic) + rendered_prompt = await self._render_prompt(self.summary_prompt, topic=self._topic) chat_history.messages.insert( 0, ChatMessageContent(role=AuthorRole.SYSTEM, content=rendered_prompt), @@ -171,11 +171,11 @@ class ChatCompletionGroupChatManager(GroupChatManager): ChatMessageContent(role=AuthorRole.USER, content="Summarize the plan."), ) - response = await self.service.get_chat_message_content( + response = await self._service.get_chat_message_content( chat_history, settings=PromptExecutionSettings(response_format=StringResult), ) - string_result = StringResult.model_validate_json(response.content) + string_result = StringResult.model_validate_json(response.content) # type: ignore return MessageResult( result=ChatMessageContent(role=AuthorRole.ASSISTANT, content=string_result.result), reason=string_result.reason, @@ -197,7 +197,7 @@ async def sk_agent_response_callback(message: ChatMessageContent | Sequence[Chat async def run_semantic_kernel_example(task: str) -> str: credential = AzureCliCredential() orchestration = GroupChatOrchestration( - members=build_semantic_kernel_agents(), + members=build_semantic_kernel_agents(), # type: ignore manager=ChatCompletionGroupChatManager( topic=DISCUSSION_TOPIC, service=AzureChatCompletion(credential=credential), @@ -225,7 +225,7 @@ async def run_semantic_kernel_example(task: str) -> str: async def run_agent_framework_example(task: str) -> str: - credential = AzureCliCredential() + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) researcher = Agent( name="Researcher", @@ -234,32 +234,42 @@ async def run_agent_framework_example(task: str) -> str: "Gather concise facts or considerations that help plan a community hackathon. " "Keep your responses factual and scannable." ), - client=FoundryChatClient(credential=credential), + client=client, ) planner = Agent( name="Planner", description="Turns the collected notes into a concrete action plan.", instructions=("Propose a structured action plan that accounts for logistics, roles, and timeline."), - client=FoundryChatClient(credential=credential), + client=client, ) workflow = GroupChatBuilder( participants=[researcher, planner], - orchestrator_agent=Agent(client=FoundryChatClient(credential=credential)), + orchestrator_agent=Agent(client=client), + max_rounds=8, + intermediate_outputs=True, ).build() - final_response = "" + output_messages: list[Message] = [] + last_message_id: str | None = None async for event in workflow.run(task, stream=True): if event.type == "output": - data = event.data - if isinstance(data, list) and len(data) > 0: - # Get the final message from the conversation - final_message = data[-1] - final_response = final_message.text or "" if isinstance(final_message, Message) else str(data) + if isinstance(event.data, AgentResponseUpdate): + if event.data.message_id != last_message_id: + last_message_id = event.data.message_id + print(f"{event.data.author_name}: {event.data.text}", end="") + else: + print(event.data.text, end="") else: - final_response = str(data) - return final_response + output_messages.extend(cast(list[Message], event.data)) + for message in output_messages: + print(f"[{message.author_name}] {message.text}") + + if output_messages: + return output_messages[-1].text + + return "" async def main() -> None: diff --git a/python/samples/semantic-kernel-migration/orchestrations/handoff.py b/python/samples/semantic-kernel-migration/orchestrations/handoff.py index 689de88bde..bdc2ed49e6 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/handoff.py +++ b/python/samples/semantic-kernel-migration/orchestrations/handoff.py @@ -11,15 +11,14 @@ """Side-by-side handoff orchestrations for Semantic Kernel and Agent Framework.""" import asyncio -import sys -from collections.abc import AsyncIterable, Iterator, Sequence +from collections.abc import AsyncIterable, Callable, Iterator, Sequence from agent_framework import ( Agent, Message, WorkflowEvent, ) -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -36,11 +35,6 @@ from semantic_kernel.contents import ( ) from semantic_kernel.functions import kernel_function -if sys.version_info >= (3, 12): - pass # pragma: no cover -else: - pass # pragma: no cover - # Load environment variables from .env file load_dotenv() @@ -149,7 +143,7 @@ def _sk_streaming_callback(message: StreamingChatMessageContent, is_final: bool) _sk_new_message = True -def _make_sk_human_responder(script: Iterator[str]) -> callable: +def _make_sk_human_responder(script: Iterator[str]) -> Callable[[], ChatMessageContent]: def _responder() -> ChatMessageContent: try: user_text = next(script) @@ -190,7 +184,7 @@ async def run_semantic_kernel_example(initial_task: str, scripted_responses: Seq ###################################################################### -def _create_af_agents(client: FoundryChatClient): +def _create_af_agents(client: OpenAIChatCompletionClient): triage = Agent( client=client, name="triage_agent", @@ -245,7 +239,7 @@ def _extract_final_conversation(events: list[WorkflowEvent]) -> list[Message]: async def run_agent_framework_example(initial_task: str, scripted_responses: Sequence[str]) -> str: - client = FoundryChatClient(credential=AzureCliCredential()) + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) triage, refund, status, returns = _create_af_agents(client) workflow = ( @@ -272,7 +266,7 @@ async def run_agent_framework_example(initial_task: str, scripted_responses: Seq user_reply = next(scripted_iter) except StopIteration: user_reply = "Thanks, that's all." - responses = {request.request_id: [Message(role="user", text=user_reply)] for request in pending} + responses = {request.request_id: [Message(role="user", contents=[user_reply])] for request in pending} final_events = await _drain_events(workflow.run(stream=True, responses=responses)) pending = _collect_handoff_requests(final_events) @@ -281,7 +275,7 @@ async def run_agent_framework_example(initial_task: str, scripted_responses: Seq return "" # Render final transcript succinctly. - lines = [] + lines: list[str] = [] for message in conversation: text = message.text or "" if not text.strip(): diff --git a/python/samples/semantic-kernel-migration/orchestrations/magentic.py b/python/samples/semantic-kernel-migration/orchestrations/magentic.py index 2594d15f89..dcf7b1af33 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/magentic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/magentic.py @@ -13,9 +13,10 @@ import asyncio from collections.abc import Sequence +from typing import cast -from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient +from agent_framework import Agent, AgentResponseUpdate, Message +from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import MagenticBuilder from dotenv import load_dotenv from semantic_kernel.agents import ( @@ -46,22 +47,22 @@ PROMPT = ( ###################################################################### -async def build_semantic_kernel_agents() -> list: +async def build_semantic_kernel_agents() -> list[ChatCompletionAgent | OpenAIAssistantAgent]: research_agent = ChatCompletionAgent( name="ResearchAgent", description="A helpful assistant with access to web search. Ask it to perform web searches.", instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), - service=OpenAIChatCompletion(ai_model_id="gpt-4o-search-preview"), + service=OpenAIChatCompletion(ai_model="gpt-4o-mini-search-preview"), ) client = OpenAIAssistantAgent.create_client() code_interpreter_tool, code_interpreter_tool_resources = OpenAIAssistantAgent.configure_code_interpreter_tool() openai_settings = OpenAISettings() - model_id = openai_settings.chat_model_id if openai_settings.chat_model_id else "gpt-5" - definition = await client.beta.assistants.create( - model=model_id, + model = openai_settings.chat_model if openai_settings.chat_model else "gpt-5" + definition = await client.beta.assistants.create( # pyright: ignore[reportDeprecated] + model=model, name="CoderAgent", description="A helpful assistant that writes and executes code to process and analyze data.", instructions="You solve questions using code. Please provide detailed analysis and computation process.", @@ -94,7 +95,7 @@ def sk_agent_response_callback( async def run_semantic_kernel_example(prompt: str) -> Sequence[ChatMessageContent]: agents = await build_semantic_kernel_agents() magentic_orchestration = MagenticOrchestration( - members=agents, + members=agents, # type: ignore manager=StandardMagenticManager(chat_completion_service=OpenAIChatCompletion()), agent_response_callback=sk_agent_response_callback, ) @@ -137,12 +138,12 @@ async def run_agent_framework_example(prompt: str) -> str | None: instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), - client=OpenAIChatClient(model="gpt-4o-search-preview"), + client=OpenAIChatClient(model="gpt-4o-mini-search-preview"), ) # Create code interpreter tool using static method - coder_client = OpenAIResponsesClient() - code_interpreter_tool = OpenAIResponsesClient.get_code_interpreter_tool() + coder_client = OpenAIChatClient() + code_interpreter_tool = OpenAIChatClient.get_code_interpreter_tool() coder = Agent( name="CoderAgent", @@ -160,22 +161,31 @@ async def run_agent_framework_example(prompt: str) -> str | None: client=OpenAIChatClient(), ) - workflow = MagenticBuilder(participants=[researcher, coder], manager_agent=manager_agent).build() + workflow = MagenticBuilder( + participants=[researcher, coder], + manager_agent=manager_agent, # type: ignore + intermediate_outputs=True, + ).build() - final_text: str | None = None + output_messages: list[Message] = [] + last_message_id: str | None = None async for event in workflow.run(prompt, stream=True): if event.type == "output": - data = event.data - if isinstance(data, str): - final_text = data - elif isinstance(data, list): - # Extract text from the last assistant message - for msg in reversed(data): - if hasattr(msg, "text") and msg.text: - final_text = msg.text - break + if isinstance(event.data, AgentResponseUpdate): + if event.data.message_id != last_message_id: + last_message_id = event.data.message_id + print(f"{event.data.author_name}: {event.data.text}", end="") + else: + print(event.data.text, end="") + else: + output_messages.extend(cast(list[Message], event.data)) + for message in output_messages: + print(f"[{message.author_name}] {message.text}") - return final_text + if output_messages: + return output_messages[-1].text + + return None def _print_agent_framework_output(result: str | None) -> None: diff --git a/python/samples/semantic-kernel-migration/orchestrations/sequential.py b/python/samples/semantic-kernel-migration/orchestrations/sequential.py index fd9794fdfe..51a6eb78cc 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/sequential.py +++ b/python/samples/semantic-kernel-migration/orchestrations/sequential.py @@ -16,7 +16,7 @@ from collections.abc import Sequence from typing import cast from agent_framework import Agent, Message -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -76,7 +76,7 @@ async def sk_agent_response_callback( async def run_agent_framework_example(prompt: str) -> list[Message]: - client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) writer = Agent(client=client, instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), diff --git a/python/scripts/dependencies/_dependency_bounds_upper_impl.py b/python/scripts/dependencies/_dependency_bounds_upper_impl.py index 239e7dd04a..352a251c36 100644 --- a/python/scripts/dependencies/_dependency_bounds_upper_impl.py +++ b/python/scripts/dependencies/_dependency_bounds_upper_impl.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse import concurrent.futures import json +import logging import os import re import shutil @@ -33,6 +34,8 @@ from scripts.dependencies._dependency_bounds_runtime import ( ) from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches +logger = logging.getLogger(__name__) + CHECK_TASK_PRIORITY = ("check", "typing", "pyright", "mypy", "lint") REQ_PATTERN = r"^\s*([A-Za-z0-9_.-]+(?:\[[^\]]+\])?)\s*(.*?)\s*$" SECTION_HEADER_PATTERN = re.compile(r"^\s*\[([^\]]+)\]\s*$") @@ -264,6 +267,12 @@ def _collect_dev_pin_replacements( project = data.get("project", {}) or {} optional_dependencies = project.get("optional-dependencies", {}) or {} dependency_groups = data.get("dependency-groups", {}) or {} + logger.debug( + "Collecting dev dependency replacements from %s with optional_dependencies=%s and dependency_groups=%s", + pyproject_file, + optional_dependencies.keys(), + dependency_groups.keys(), + ) workspace_versions = _load_workspace_package_versions(str(pyproject_file.parent.parent.parent.resolve())) dev_requirements: list[str] = [] @@ -273,6 +282,7 @@ def _collect_dev_pin_replacements( dev_requirements.extend( requirement for requirement in (dependency_groups.get("dev", []) or []) if isinstance(requirement, str) ) + logger.debug(f"Found {len(dev_requirements)} dev requirements in {pyproject_file}") seen_requirements: set[str] = set() replacements: dict[str, str] = {} @@ -293,9 +303,10 @@ def _collect_dev_pin_replacements( if dependency_name.startswith("agent-framework"): latest_version = workspace_versions.get(dependency_name) else: - latest_version = _select_latest_dev_version(catalog.get_lock(dependency_name)) - if latest_version is None: - latest_version = _select_latest_dev_version(catalog.get(dependency_name)) + # Dev-tool refreshes should follow the selected version source (PyPI by default) + # instead of being pinned by the current lockfile. VersionCatalog already falls + # back to lock data when PyPI cannot be reached or --version-source=lock is used. + latest_version = _select_latest_dev_version(catalog.get(dependency_name)) if latest_version is None: continue diff --git a/python/scripts/dependencies/upgrade_dev_dependencies.py b/python/scripts/dependencies/upgrade_dev_dependencies.py index 7ea2067af5..0dbb53d6b7 100644 --- a/python/scripts/dependencies/upgrade_dev_dependencies.py +++ b/python/scripts/dependencies/upgrade_dev_dependencies.py @@ -5,6 +5,7 @@ from __future__ import annotations +import logging import argparse from dataclasses import dataclass from pathlib import Path @@ -20,6 +21,7 @@ from scripts.dependencies._dependency_bounds_upper_impl import ( ) from scripts.task_runner import discover_projects +logger = logging.getLogger(__name__) @dataclass(frozen=True) class WorkspaceProject: @@ -125,7 +127,13 @@ def main() -> None: action="store_true", help="Print planned replacements without updating files.", ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show debug logging.", + ) args = parser.parse_args() + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s") workspace_root = Path(__file__).resolve().parents[2] lock_versions = _load_lock_versions(workspace_root) @@ -137,6 +145,7 @@ def main() -> None: _discover_workspace_projects(workspace_root), package_filters=args.packages, ) + logger.debug(f"Selected projects for dev dependency refresh: {[project.pyproject_path for project in selected_projects]}") if not selected_projects: filters = ", ".join(args.packages or []) raise SystemExit(f"No matching workspace projects found for: {filters}") diff --git a/python/scripts/sample_validation/create_dynamic_workflow_executor.py b/python/scripts/sample_validation/create_dynamic_workflow_executor.py index 44a7e5a0c5..66ba578228 100644 --- a/python/scripts/sample_validation/create_dynamic_workflow_executor.py +++ b/python/scripts/sample_validation/create_dynamic_workflow_executor.py @@ -134,7 +134,7 @@ class CustomAgentExecutor(Executor): [ Message( role="user", - text=f"Validate the following sample:\n\n{sample.relative_path}", + contents=[f"Validate the following sample:\n\n{sample.relative_path}"], ) ], session=self._session, diff --git a/python/uv.lock b/python/uv.lock index 590a78c791..fef2474e59 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -30,7 +30,6 @@ members = [ "agent-framework-a2a", "agent-framework-ag-ui", "agent-framework-anthropic", - "agent-framework-azure-ai", "agent-framework-azure-ai-search", "agent-framework-azure-cosmos", "agent-framework-azurefunctions", @@ -94,7 +93,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0rc6" +version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -127,27 +126,27 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ { name = "flit", specifier = "==3.12.0" }, - { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, - { name = "mypy", specifier = "==1.19.1" }, - { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, + { name = "mcp", extras = ["ws"], specifier = "==1.26.0" }, + { name = "mypy", specifier = "==1.20.0" }, + { name = "opentelemetry-sdk", specifier = "==1.40.0" }, { name = "poethepoet", specifier = "==0.42.1" }, - { name = "prek", specifier = "==0.3.4" }, + { name = "prek", specifier = "==0.3.8" }, { name = "pyright", specifier = "==1.1.408" }, { name = "pytest", specifier = "==9.0.2" }, { name = "pytest-asyncio", specifier = "==1.3.0" }, - { name = "pytest-cov", specifier = "==7.0.0" }, + { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-retry", specifier = "==1.7.0" }, { name = "pytest-timeout", specifier = "==2.4.0" }, { name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" }, - { name = "rich", specifier = "==13.7.1" }, - { name = "ruff", specifier = "==0.15.5" }, - { name = "tomli", specifier = "==2.4.0" }, - { name = "uv", specifier = "==0.10.9" }, + { name = "rich", specifier = ">=13.7.1,<15.0.0" }, + { name = "ruff", specifier = "==0.15.8" }, + { name = "tomli", specifier = "==2.4.1" }, + { name = "uv", specifier = "==0.11.3" }, ] [[package]] name = "agent-framework-a2a" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -162,7 +161,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -190,7 +189,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -203,34 +202,9 @@ requires-dist = [ { name = "anthropic", specifier = ">=0.80.0,<0.80.1" }, ] -[[package]] -name = "agent-framework-azure-ai" -version = "1.0.0rc6" -source = { editable = "packages/azure-ai" } -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-agents", 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 = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] - -[package.metadata] -requires-dist = [ - { name = "agent-framework-core", editable = "packages/core" }, - { name = "agent-framework-openai", editable = "packages/openai" }, - { name = "aiohttp", specifier = ">=3.7.0,<4" }, - { name = "azure-ai-agents", specifier = ">=1.2.0b5,<1.2.0b6" }, - { name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" }, - { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, - { name = "azure-identity", specifier = ">=1,<2" }, -] - [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -245,7 +219,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-cosmos" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/azure-cosmos" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -260,7 +234,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -282,7 +256,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -299,7 +273,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -314,7 +288,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -329,7 +303,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -344,7 +318,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0rc6" +version = "1.0.0" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -358,8 +332,8 @@ 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", 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-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'" }, @@ -386,8 +360,8 @@ requires-dist = [ { name = "agent-framework-a2a", marker = "extra == 'all'", editable = "packages/a2a" }, { name = "agent-framework-ag-ui", marker = "extra == 'all'", editable = "packages/ag-ui" }, { name = "agent-framework-anthropic", marker = "extra == 'all'", editable = "packages/anthropic" }, - { name = "agent-framework-azure-ai", marker = "extra == 'all'", editable = "packages/azure-ai" }, { name = "agent-framework-azure-ai-search", marker = "extra == 'all'", editable = "packages/azure-ai-search" }, + { 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-bedrock", marker = "extra == 'all'", editable = "packages/bedrock" }, { name = "agent-framework-chatkit", marker = "extra == 'all'", editable = "packages/chatkit" }, @@ -416,7 +390,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -441,7 +415,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -479,7 +453,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -502,15 +476,16 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260305" }] +dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }] [[package]] name = "agent-framework-foundry" -version = "1.0.0rc6" +version = "1.0.0" 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 = "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'" }, ] @@ -518,12 +493,13 @@ dependencies = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-openai", editable = "packages/openai" }, + { name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" }, { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, ] [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -540,7 +516,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -555,7 +531,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -580,7 +556,7 @@ math = [ tau2 = [ { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, ] @@ -621,22 +597,22 @@ provides-extras = ["gaia", "lightning", "tau2", "math"] [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = "==1.19.1" }, + { name = "mypy", specifier = "==1.20.0" }, { name = "poethepoet", specifier = "==0.42.1" }, - { name = "prek", specifier = "==0.3.4" }, + { name = "prek", specifier = "==0.3.8" }, { name = "pyright", specifier = "==1.1.408" }, { name = "pytest", specifier = "==9.0.2" }, - { name = "rich", specifier = "==13.7.1" }, - { name = "ruff", specifier = "==0.15.5" }, + { name = "rich", specifier = ">=13.7.1,<15.0.0" }, + { name = "ruff", specifier = "==0.15.8" }, { name = "tau2", git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619" }, - { name = "tomli", specifier = "==2.4.0" }, + { name = "tomli", specifier = "==2.4.1" }, { name = "tomli-w", specifier = "==1.2.0" }, - { name = "uv", specifier = "==0.10.9" }, + { name = "uv", specifier = "==0.11.3" }, ] [[package]] name = "agent-framework-mem0" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -651,7 +627,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -666,24 +642,22 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.0.0rc6" +version = "1.0.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 = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "openai", specifier = ">=1.99.0,<3" }, - { name = "packaging", specifier = ">=24.1,<25" }, ] [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -694,7 +668,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260330" +version = "1.0.0b260402" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -711,12 +685,12 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260330" +version = "1.0.0b260402" 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.2.6", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, ] @@ -792,7 +766,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -804,110 +778,110 @@ dependencies = [ { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, + { url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" }, + { url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" }, + { url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" }, + { url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" }, + { url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" }, + { url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] @@ -962,16 +936,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", 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 = "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')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -1022,20 +996,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "azure-ai-agents" -version = "1.2.0b5" -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'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/57/8adeed578fa8984856c67b4229e93a58e3f6024417d448d0037aafa4ee9b/azure_ai_agents-1.2.0b5.tar.gz", hash = "sha256:1a16ef3f305898aac552269f01536c34a00473dedee0bca731a21fdb739ff9d5", size = 394876, upload-time = "2025-09-30T01:55:02.328Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/6d/15070d23d7a94833a210da09d5d7ed3c24838bb84f0463895e5d159f1695/azure_ai_agents-1.2.0b5-py3-none-any.whl", hash = "sha256:257d0d24a6bf13eed4819cfa5c12fb222e5908deafb3cbfd5711d3a511cc4e88", size = 217948, upload-time = "2025-09-30T01:55:04.155Z" }, -] - [[package]] name = "azure-ai-inference" version = "1.0.0b9" @@ -1207,30 +1167,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.73" +version = "1.42.81" 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/8b/d00575be514744ca4839e7d85bf4a8a3c7b6b4574433291e58d14c68ae09/boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae", size = 112775, upload-time = "2026-03-20T19:39:52.814Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/4d/40029c26b535c41333a0b11573127cfc548fdcb1cbcd1798ea7046c56bab/boto3-1.42.81.tar.gz", hash = "sha256:e5c0d57229763007151be6d388319514a040ccdc922fbb27e37c3100a7fbc01a", size = 112785, upload-time = "2026-04-01T19:35:34.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/05/1fcf03d90abaa3d0b42a6bfd10231dd709493ecbacf794aa2eea5eae6841/boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455", size = 140556, upload-time = "2026-03-20T19:39:50.298Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/a1a8e8bbaaa258645fe04bb6a39d7d57b6a12650312f880b8e9add638a56/boto3-1.42.81-py3-none-any.whl", hash = "sha256:216f43e308f1f65e69f57784e5042ffcb2eb6a45e370d118ea384510c148fde7", size = 140554, upload-time = "2026-04-01T19:35:32.71Z" }, ] [[package]] name = "botocore" -version = "1.42.73" +version = "1.42.81" 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/b0bb9a8768398fb131e1fe722c9cc5b18f74d21ca1970efe8576912b2c6e/botocore-1.42.81.tar.gz", hash = "sha256:48e6f6f52de1cc107a34810309b8ca998ea9bb719a3fe4c06f903a604b3138cb", size = 15129980, upload-time = "2026-04-01T19:35:23.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/c7a01649a6cb7219b233d2ed071ab925e52cdb64e15ce935024c0007376f/botocore-1.42.81-py3-none-any.whl", hash = "sha256:bcef8c93c20ebeba95e4f8b9edfbffbc78a0e11235425a92ee32e48fd8e03c37", size = 14807198, upload-time = "2026-04-01T19:35:20.437Z" }, ] [[package]] @@ -1326,107 +1286,107 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.6" +version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -1463,7 +1423,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", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] 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 = [ @@ -1570,7 +1530,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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')" }, ] 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 = [ @@ -1779,62 +1739,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.6" 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 = "typing-extensions", 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')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, ] [[package]] @@ -1848,14 +1808,14 @@ wheels = [ [[package]] name = "deepdiff" -version = "8.6.2" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "orderly-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/50/767448e792d41bfb6094ee317a355c1cb221dca24b2e178e2203bbea2a77/deepdiff-8.6.2.tar.gz", hash = "sha256:186dcbd181e4d76cef11ab05f802d0056c5d6083c5a6748c1473e9d7481e183e", size = 634860, upload-time = "2026-03-18T17:16:33.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/20/63dd34163ed07393968128dc8c7ab948c96e47c4ce76976ea533de64909d/deepdiff-9.0.0.tar.gz", hash = "sha256:4872005306237b5b50829803feff58a1dfd20b2b357a55de22e7ded65b2008a7", size = 151952, upload-time = "2026-03-30T05:52:23.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/5f/c52bd1255db763d0cdcb7084d2e90c42119cb229302c56bdf1d0aa78abd2/deepdiff-8.6.2-py3-none-any.whl", hash = "sha256:4d22034a866c3928303a9332c279362f714192d9305bac17c498720d095fd1b4", size = 91979, upload-time = "2026-03-18T17:16:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c4/da7089cd7aa4ab554f56e18a7fb08dcfed8fd2ae91fa528f5b1be207a148/deepdiff-9.0.0-py3-none-any.whl", hash = "sha256:b1ae0dd86290d86a03de5fbee728fde43095c1472ae4974bdab23ab4656305bd", size = 170540, upload-time = "2026-03-30T05:52:22.008Z" }, ] [[package]] @@ -2308,11 +2268,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.2.0" +version = "2026.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, ] [[package]] @@ -2347,7 +2307,7 @@ wheels = [ [[package]] name = "google-api-core" -version = "2.30.0" +version = "2.30.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2356,9 +2316,9 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/0b/b6e296aff70bef900766934cf4e83eaacc3f244adb61936b66d24b204080/google_api_core-2.30.1.tar.gz", hash = "sha256:7304ef3bd7e77fd26320a36eeb75868f9339532bfea21694964f4765b37574ee", size = 176742, upload-time = "2026-03-30T22:50:52.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, + { url = "https://files.pythonhosted.org/packages/43/86/a00ea4596780ef3f0721c1f073c0c5ae992da4f35cf12f0d8c92d19267a6/google_api_core-2.30.1-py3-none-any.whl", hash = "sha256:3be893babbb54a89c6807b598383ddf212112130e3d24d06c681b5d18f082e08", size = 173238, upload-time = "2026-03-30T22:48:50.586Z" }, ] [[package]] @@ -2376,14 +2336,14 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.73.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, ] [[package]] @@ -2450,76 +2410,73 @@ wheels = [ ] [[package]] -name = "griffe" -version = "1.15.0" +name = "griffelib" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] name = "grpcio" -version = "1.78.0" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, - { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, - { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, - { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, - { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, - { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, - { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, - { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, - { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, - { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, - { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, - { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, - { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] @@ -2558,34 +2515,34 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.4.2" +version = "1.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357, upload-time = "2026-03-13T06:58:51.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/06/e8cf74c3c48e5485c7acc5a990d0d8516cdfb5fdf80f799174f1287cc1b5/hf_xet-1.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ac8202ae1e664b2c15cdfc7298cbb25e80301ae596d602ef7870099a126fcad4", size = 3796125, upload-time = "2026-03-13T06:58:33.177Z" }, - { url = "https://files.pythonhosted.org/packages/66/d4/b73ebab01cbf60777323b7de9ef05550790451eb5172a220d6b9845385ec/hf_xet-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6d2f8ee39fa9fba9af929f8c0d0482f8ee6e209179ad14a909b6ad78ffcb7c81", size = 3555985, upload-time = "2026-03-13T06:58:31.797Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e7/ded6d1bd041c3f2bca9e913a0091adfe32371988e047dd3a68a2463c15a2/hf_xet-1.4.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4642a6cf249c09da8c1f87fe50b24b2a3450b235bf8adb55700b52f0ea6e2eb6", size = 4212085, upload-time = "2026-03-13T06:58:24.323Z" }, - { url = "https://files.pythonhosted.org/packages/97/c1/a0a44d1f98934f7bdf17f7a915b934f9fca44bb826628c553589900f6df8/hf_xet-1.4.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:769431385e746c92dc05492dde6f687d304584b89c33d79def8367ace06cb555", size = 3988266, upload-time = "2026-03-13T06:58:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/7a/82/be713b439060e7d1f1d93543c8053d4ef2fe7e6922c5b31642eaa26f3c4b/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9dd1c1bc4cc56168f81939b0e05b4c36dd2d28c13dc1364b17af89aa0082496", size = 4188513, upload-time = "2026-03-13T06:58:40.858Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287, upload-time = "2026-03-13T06:58:42.601Z" }, - { url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574, upload-time = "2026-03-13T06:58:53.881Z" }, - { url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760, upload-time = "2026-03-13T06:58:52.187Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0f/fcd2504015eab26358d8f0f232a1aed6b8d363a011adef83fe130bff88f7/hf_xet-1.4.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:949dcf88b484bb9d9276ca83f6599e4aa03d493c08fc168c124ad10b2e6f75d7", size = 3796493, upload-time = "2026-03-13T06:58:39.267Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/19c25105ff81731ca6d55a188b5de2aa99d7a2644c7aa9de1810d5d3b726/hf_xet-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41659966020d59eb9559c57de2cde8128b706a26a64c60f0531fa2318f409418", size = 3555797, upload-time = "2026-03-13T06:58:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/8933c073186849b5e06762aa89847991d913d10a95d1603eb7f2c3834086/hf_xet-1.4.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c588e21d80010119458dd5d02a69093f0d115d84e3467efe71ffb2c67c19146", size = 4212127, upload-time = "2026-03-13T06:58:30.539Z" }, - { url = "https://files.pythonhosted.org/packages/eb/01/f89ebba4e369b4ed699dcb60d3152753870996f41c6d22d3d7cac01310e1/hf_xet-1.4.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a296744d771a8621ad1d50c098d7ab975d599800dae6d48528ba3944e5001ba0", size = 3987788, upload-time = "2026-03-13T06:58:29.139Z" }, - { url = "https://files.pythonhosted.org/packages/84/4d/8a53e5ffbc2cc33bbf755382ac1552c6d9af13f623ed125fe67cc3e6772f/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f563f7efe49588b7d0629d18d36f46d1658fe7e08dce3fa3d6526e1c98315e2d", size = 4188315, upload-time = "2026-03-13T06:58:48.017Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b8/b7a1c1b5592254bd67050632ebbc1b42cc48588bf4757cb03c2ef87e704a/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5b2e0132c56d7ee1bf55bdb638c4b62e7106f6ac74f0b786fed499d5548c5570", size = 4428306, upload-time = "2026-03-13T06:58:49.502Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0c/40779e45b20e11c7c5821a94135e0207080d6b3d76e7b78ccb413c6f839b/hf_xet-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2f45c712c2fa1215713db10df6ac84b49d0e1c393465440e9cb1de73ecf7bbf6", size = 3665826, upload-time = "2026-03-13T06:58:59.88Z" }, - { url = "https://files.pythonhosted.org/packages/51/4c/e2688c8ad1760d7c30f7c429c79f35f825932581bc7c9ec811436d2f21a0/hf_xet-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6d53df40616f7168abfccff100d232e9d460583b9d86fa4912c24845f192f2b8", size = 3529113, upload-time = "2026-03-13T06:58:58.491Z" }, - { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, - { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, - { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" }, - { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" }, - { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload-time = "2026-03-31T22:39:49.618Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload-time = "2026-03-31T22:39:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload-time = "2026-03-31T22:39:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, + { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] [[package]] @@ -2612,11 +2569,11 @@ wheels = [ [[package]] name = "httpdbg" -version = "2.1.5" +version = "2.1.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/38/b0baca0ca28825b87da1ae2a4232e8e81529ec2b2aca574288faac82e3ad/httpdbg-2.1.5.tar.gz", hash = "sha256:36b19cf80669f419759a5ecfd07c3076dc5111ef40c3b92cb78f92e869fbf098", size = 80681, upload-time = "2025-11-23T14:50:06.659Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/47/69b5ceb2bbd03657b0a1458d8f3fdccd331d05bfa139569e6f293a2c4353/httpdbg-2.1.6.tar.gz", hash = "sha256:7f0925718faa0c94f5855075b168dc95bb16f9cd6826eb8449c8af9e720ea42b", size = 80616, upload-time = "2026-03-28T11:04:27.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/bf/e4f7eb84ae3739e0138ce2e1892d99c5192355739c8403d5c572c599e5ac/httpdbg-2.1.5-py3-none-any.whl", hash = "sha256:57e353b4cefb37b4f6862b5b3e6c0e9da92999e94dc54fd393c9143b6644e89e", size = 88161, upload-time = "2025-11-23T14:50:05.223Z" }, + { url = "https://files.pythonhosted.org/packages/13/a8/6d101e4d58563e8647fe010a5ae61ba41ceb61e2abd789170573599d7d43/httpdbg-2.1.6-py3-none-any.whl", hash = "sha256:e3d5be9cd5eeb262b77e4faf113c356b7da127bb848b3360c99e394bb2dc9590", size = 87938, upload-time = "2026-03-28T11:04:26.035Z" }, ] [[package]] @@ -2693,7 +2650,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.7.2" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2706,9 +2663,9 @@ dependencies = [ { name = "typer", 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b", size = 724684, upload-time = "2026-03-20T10:36:08.767Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/2a/a847fd02261cd051da218baf99f90ee7c7040c109a01833db4f838f25256/huggingface_hub-1.8.0.tar.gz", hash = "sha256:c5627b2fd521e00caf8eff4ac965ba988ea75167fad7ee72e17f9b7183ec63f3", size = 735839, upload-time = "2026-03-25T16:01:28.152Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/8a3a16ea4d202cb641b51d2681bdd3d482c1c592d7570b3fa264730829ce/huggingface_hub-1.8.0-py3-none-any.whl", hash = "sha256:d3eb5047bd4e33c987429de6020d4810d38a5bef95b3b40df9b17346b7f353f2", size = 625208, upload-time = "2026-03-25T16:01:26.603Z" }, ] [[package]] @@ -3057,12 +3014,11 @@ wheels = [ [[package]] name = "langfuse" -version = "4.0.1" +version = "4.0.6" 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 = "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-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'" }, @@ -3070,9 +3026,9 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749, upload-time = "2026-03-19T14:03:34.508Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/d0/6d79ed5614f86f27f5df199cf10c6facf6874ff6f91b828ae4dad90aa86d/langfuse-4.0.6.tar.gz", hash = "sha256:83a6f8cc8f1431fa2958c91e2673bc4179f993297e9b1acd1dbf001785e6cf83", size = 274094, upload-time = "2026-04-01T20:04:15.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049, upload-time = "2026-03-19T14:03:32.527Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/088048e37b6d7ec1b52c6a11bc33101454285a22eaab8303dcccfd78344d/langfuse-4.0.6-py3-none-any.whl", hash = "sha256:0562b1dcf83247f9d8349f0f755eaed9a7f952fee67e66580970f0738bf3adbf", size = 472841, upload-time = "2026-04-01T20:04:16.451Z" }, ] [[package]] @@ -3278,11 +3234,11 @@ wheels = [ [[package]] name = "litellm-proxy-extras" -version = "0.4.60" +version = "0.4.62" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/00/828092491c0106657f9cb9ee43ac6ed71d13e9eba627d1e81c0c68b6126d/litellm_proxy_extras-0.4.60.tar.gz", hash = "sha256:1c122f2a7e0eb58fa4c6d8da9da82ac1fe2869de3510bcfade5c2932af202328", size = 32034, upload-time = "2026-03-22T05:54:55.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/96/fe0351bf5f8674fe62d38c8053b9bd1a270e5e71dcfd402b692ae06b72d7/litellm_proxy_extras-0.4.62.tar.gz", hash = "sha256:0d87db1cda9851717e5294f2fa2c7ae1f34d7476d99e24e6462702e11a2cdd88", size = 31957, upload-time = "2026-03-31T03:45:53.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/e8/828213b07512e673403da306a804dbe9b2965fcb7286d746c4bbff585b61/litellm_proxy_extras-0.4.60-py3-none-any.whl", hash = "sha256:7abcc811f7430e4b24e7a8ba7186219a4845a955ae7a71d8822bd03fd9fc3393", size = 76605, upload-time = "2026-03-22T05:54:54.41Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/0379144431c24c64fe08914afca05ba45d27a583ac05cfa15d79a9ff702a/litellm_proxy_extras-0.4.62-py3-none-any.whl", hash = "sha256:cf91c1a83d94000b8997ee29d9e8d505d1ad80c26f111803bef5365174c97de0", size = 76189, upload-time = "2026-03-31T03:45:52.114Z" }, ] [[package]] @@ -3406,7 +3362,7 @@ dependencies = [ { 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.2.6", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, @@ -3511,7 +3467,7 @@ wheels = [ [[package]] name = "mem0ai" -version = "1.0.7" +version = "1.0.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3522,9 +3478,9 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/a7/ad272ecb8e67690d08f6fb59d7162260dbbbaf9f1b9336c52bef254bfbcd/mem0ai-1.0.7.tar.gz", hash = "sha256:57ec923d9703cd0f9bc0ffac511d2839ed99448a28c9ad8c017335083846d338", size = 188641, upload-time = "2026-03-20T22:46:24.734Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/d9/1fbf24b055f9f14ec61d593bc95b75b033fd1a69bfddee33d6453b21e5d0/mem0ai-1.0.10.tar.gz", hash = "sha256:f3e22c9aff695ca6c66631c4e79ceef92457c8d3355c56359ab4257fa031c046", size = 200416, upload-time = "2026-04-01T18:23:27.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/8e/c7a6dbf2dfadd603fbfb17cd9c201b26a98382cb5c5e4c07b101978faf09/mem0ai-1.0.7-py3-none-any.whl", hash = "sha256:a0a2e9d75ef0e98f9b2579e2d40beb05b87b9e05b65304084c13b0d408ee5f1c", size = 287025, upload-time = "2026-03-20T22:46:23.238Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/2c6ea68c404757e683da23b942dfff6987fe283ccbf2fa1fb0c128ddbdc6/mem0ai-1.0.10-py3-none-any.whl", hash = "sha256:9ff586c3a39a834042ce6755fc9da2315e284fb622ee773cd344ecca756ccad5", size = 295374, upload-time = "2026-04-01T18:23:25.022Z" }, ] [[package]] @@ -3573,7 +3529,7 @@ version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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')" }, ] 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 = [ @@ -3788,7 +3744,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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')" }, @@ -3797,39 +3753,51 @@ dependencies = [ { 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 = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, ] [[package]] @@ -3843,11 +3811,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.18.0" +version = "2.18.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/b4/02a8add181b8d2cd5da3b667cd102ae536e8c9572ab1a130816d70a89edb/narwhals-2.18.0.tar.gz", hash = "sha256:1de5cee338bc17c338c6278df2c38c0dd4290499fcf70d75e0a51d5f22a6e960", size = 620222, upload-time = "2026-03-10T15:51:27.14Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/96/45218c2fdec4c9f22178f905086e85ef1a6d63862dcc3cd68eb60f1867f5/narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b", size = 620578, upload-time = "2026-03-24T15:11:25.456Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/75/0b4a10da17a44cf13567d08a9c7632a285297e46253263f1ae119129d10a/narwhals-2.18.0-py3-none-any.whl", hash = "sha256:68378155ee706ac9c5b25868ef62ecddd62947b6df7801a0a156bc0a615d2d0d", size = 444865, upload-time = "2026-03-10T15:51:24.085Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/06490e98393dcb4d6ce2bf331a39335375c300afaef526897881fbeae6ab/narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad", size = 444952, upload-time = "2026-03-24T15:11:23.801Z" }, ] [[package]] @@ -3928,7 +3896,7 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.3" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'darwin'", @@ -3944,79 +3912,79 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" }, - { url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" }, - { url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" }, - { url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" }, - { url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" }, - { url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" }, - { url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" }, - { url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, - { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, - { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, - { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, - { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, - { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, - { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, - { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, - { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, - { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, - { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, - { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, - { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, - { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, - { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, - { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, - { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, - { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, - { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, - { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, - { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, - { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" }, - { url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" }, - { url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" }, - { url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" }, - { url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" }, - { url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" }, - { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] [[package]] @@ -4043,7 +4011,7 @@ wheels = [ [[package]] name = "openai" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4055,17 +4023,17 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, ] [[package]] name = "openai-agents" -version = "0.13.0" +version = "0.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "griffelib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", 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'" }, @@ -4073,9 +4041,9 @@ dependencies = [ { name = "types-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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/df/68927da38588f7b9c418754f2a0c30c9cda1d8621b035906faf85767dda5/openai_agents-0.13.0.tar.gz", hash = "sha256:90ac13697dec3c110c3ed9893629e01b6fc178ae410a7f0e39f387be408e8715", size = 2660070, upload-time = "2026-03-23T06:20:23.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/db/97577be8089482d16cfe1ba3829a06910dc4e4dd8f43e99b66e5e05b99a3/openai_agents-0.13.4.tar.gz", hash = "sha256:977e27f2a51e8d95cd1c90f2378445d9b5ca77b22671d47a3e01507a03a6536a", size = 2693163, upload-time = "2026-04-01T02:38:16.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/8d/62cf7374a2050daa6b7605c12a9085fa528d493f9cf076826c0c78ac16f7/openai_agents-0.13.0-py3-none-any.whl", hash = "sha256:d1077e71e9c7461f9098922bbc63a1f2a1244c93fd3dc24249882b3130eccd55", size = 454617, upload-time = "2026-03-23T06:20:22.068Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9c/02a5753a272888a8699ca766033e1c7d29d790b645582215f6e0e066686d/openai_agents-0.13.4-py3-none-any.whl", hash = "sha256:fc7d1d661d7261a3f4153fa3fe3b2c509dc4b1ede331f41c366901c93c9adb7f", size = 469589, upload-time = "2026-04-01T02:38:14.448Z" }, ] [[package]] @@ -4254,83 +4222,83 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.7" +version = "3.11.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, - { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, - { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, - { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, - { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, - { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, - { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, - { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, - { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, - { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, - { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, - { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, - { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, - { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, - { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, - { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, - { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, - { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, - { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, - { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, - { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, - { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, - { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, - { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, - { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, - { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, - { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, - { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, - { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, - { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, - { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, - { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, - { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, - { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/5d81f61fe3e4270da80c71442864c091cee3003cc8984c75f413fe742a07/orjson-3.11.8-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e6693ff90018600c72fd18d3d22fa438be26076cd3c823da5f63f7bab28c11cb", size = 229663, upload-time = "2026-03-31T16:14:30.708Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/85e06b0eb11de6fb424120fd5788a07035bd4c5e6bb7841ae9972a0526d1/orjson-3.11.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93de06bc920854552493c81f1f729fab7213b7db4b8195355db5fda02c7d1363", size = 132321, upload-time = "2026-03-31T16:14:32.317Z" }, + { url = "https://files.pythonhosted.org/packages/86/71/089338ee51b3132f050db0864a7df9bdd5e94c2a03820ab8a91e8f655618/orjson-3.11.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe0b8c83e0f36247fc9431ce5425a5d95f9b3a689133d494831bdbd6f0bceb13", size = 130658, upload-time = "2026-03-31T16:14:33.935Z" }, + { url = "https://files.pythonhosted.org/packages/10/0d/f39d8802345d0ad65f7fd4374b29b9b59f98656dc30f21ca5c773265b2f0/orjson-3.11.8-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97d823831105c01f6c8029faf297633dbeb30271892bd430e9c24ceae3734744", size = 135708, upload-time = "2026-03-31T16:14:35.224Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b5/40aae576b3473511696dcffea84fde638b2b64774eb4dcb8b2c262729f8a/orjson-3.11.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60c0423f15abb6cf78f56dff00168a1b582f7a1c23f114036e2bfc697814d5f", size = 147047, upload-time = "2026-03-31T16:14:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f0/778a84458d1fdaa634b2e572e51ce0b354232f580b2327e1f00a8d88c38c/orjson-3.11.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01928d0476b216ad2201823b0a74000440360cef4fed1912d297b8d84718f277", size = 133072, upload-time = "2026-03-31T16:14:37.715Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/1bbf2fc3ffcc4b829ade554b574af68cec898c9b5ad6420a923c75a073d3/orjson-3.11.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4a639049c44d36a6d1ae0f4a94b271605c745aee5647fa8ffaabcdc01b69a6", size = 133867, upload-time = "2026-03-31T16:14:39.356Z" }, + { url = "https://files.pythonhosted.org/packages/08/94/6413da22edc99a69a8d0c2e83bf42973b8aa94d83ef52a6d39ac85da00bc/orjson-3.11.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3222adff1e1ff0dce93c16146b93063a7793de6c43d52309ae321234cdaf0f4d", size = 142268, upload-time = "2026-03-31T16:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/aa5dbaa6136d7ba55f5461ac2e885efc6e6349424a428927fd46d68f4396/orjson-3.11.8-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3223665349bbfb68da234acd9846955b1a0808cbe5520ff634bf253a4407009b", size = 424008, upload-time = "2026-03-31T16:14:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/fa/aa/2c1962d108c7fe5e27aa03a354b378caf56d8eafdef15fd83dec081ce45a/orjson-3.11.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:61c9d357a59465736022d5d9ba06687afb7611dfb581a9d2129b77a6fcf78e59", size = 147942, upload-time = "2026-03-31T16:14:44.256Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/65f404f4c47eb1b0b4476f03ec838cac0c4aa933920ff81e5dda4dee14e7/orjson-3.11.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58fb9b17b4472c7b1dcf1a54583629e62e23779b2331052f09a9249edf81675b", size = 136640, upload-time = "2026-03-31T16:14:45.884Z" }, + { url = "https://files.pythonhosted.org/packages/90/5f/7b784aea98bdb125a2f2da7c27d6c2d2f6d943d96ef0278bae596d563f85/orjson-3.11.8-cp310-cp310-win32.whl", hash = "sha256:b43dc2a391981d36c42fa57747a49dae793ef1d2e43898b197925b5534abd10a", size = 132066, upload-time = "2026-03-31T16:14:47.397Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/2e284af8d6c9478df5ef938917743f61d68f4c70d17f1b6e82f7e3b8dba1/orjson-3.11.8-cp310-cp310-win_amd64.whl", hash = "sha256:c98121237fea2f679480765abd566f7713185897f35c9e6c2add7e3a9900eb61", size = 127609, upload-time = "2026-03-31T16:14:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/67/41/5aa7fa3b0f4dc6b47dcafc3cea909299c37e40e9972feabc8b6a74e2730d/orjson-3.11.8-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:003646067cc48b7fcab2ae0c562491c9b5d2cbd43f1e5f16d98fd118c5522d34", size = 229229, upload-time = "2026-03-31T16:14:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d7/57e7f2458e0a2c41694f39fc830030a13053a84f837a5b73423dca1f0938/orjson-3.11.8-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ed193ce51d77a3830cad399a529cd4ef029968761f43ddc549e1bc62b40d88f8", size = 128871, upload-time = "2026-03-31T16:14:51.888Z" }, + { url = "https://files.pythonhosted.org/packages/53/4a/e0fdb9430983e6c46e0299559275025075568aad5d21dd606faee3703924/orjson-3.11.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30491bc4f862aa15744b9738517454f1e46e56c972a2be87d70d727d5b2a8f8", size = 132104, upload-time = "2026-03-31T16:14:53.142Z" }, + { url = "https://files.pythonhosted.org/packages/08/4a/2025a60ff3f5c8522060cda46612d9b1efa653de66ed2908591d8d82f22d/orjson-3.11.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eda5b8b6be91d3f26efb7dc6e5e68ee805bc5617f65a328587b35255f138bf4", size = 130483, upload-time = "2026-03-31T16:14:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3c/b9cde05bdc7b2385c66014e0620627da638d3d04e4954416ab48c31196c5/orjson-3.11.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee8db7bfb6fe03581bbab54d7c4124a6dd6a7f4273a38f7267197890f094675f", size = 135481, upload-time = "2026-03-31T16:14:55.901Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f2/a8238e7734de7cb589fed319857a8025d509c89dc52fdcc88f39c6d03d5a/orjson-3.11.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d8b5231de76c528a46b57010bbd83fb51e056aa0220a372fd5065e978406f1c", size = 146819, upload-time = "2026-03-31T16:14:57.548Z" }, + { url = "https://files.pythonhosted.org/packages/db/10/dbf1e2a3cafea673b1b4350e371877b759060d6018a998643b7040e5de48/orjson-3.11.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58a4a208a6fbfdb7a7327b8f201c6014f189f721fd55d047cafc4157af1bc62a", size = 132846, upload-time = "2026-03-31T16:14:58.91Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/55e667ec9c85694038fcff00573d221b085d50777368ee3d77f38668bf3c/orjson-3.11.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f8952d6d2505c003e8f0224ff7858d341fa4e33fef82b91c4ff0ef070f2393c", size = 133580, upload-time = "2026-03-31T16:15:00.519Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a6/c08c589a9aad0cb46c4831d17de212a2b6901f9d976814321ff8e69e8785/orjson-3.11.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0022bb50f90da04b009ce32c512dc1885910daa7cb10b7b0cba4505b16db82a8", size = 142042, upload-time = "2026-03-31T16:15:01.906Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/2f78ea241d52b717d2efc38878615fe80425bf2beb6e68c984dde257a766/orjson-3.11.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6", size = 423845, upload-time = "2026-03-31T16:15:03.703Z" }, + { url = "https://files.pythonhosted.org/packages/70/07/c17dcf05dd8045457538428a983bf1f1127928df5bf328cb24d2b7cddacb/orjson-3.11.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6dbe9a97bdb4d8d9d5367b52a7c32549bba70b2739c58ef74a6964a6d05ae054", size = 147729, upload-time = "2026-03-31T16:15:05.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/6c/0fb6e8a24e682e0958d71711ae6f39110e4b9cd8cab1357e2a89cb8e1951/orjson-3.11.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5c370674ebabe16c6ccac33ff80c62bf8a6e59439f5e9d40c1f5ab8fd2215b7", size = 136425, upload-time = "2026-03-31T16:15:07.052Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/4d3cc3a3d616035beb51b24a09bb872942dc452cf2df0c1d11ab35046d9f/orjson-3.11.8-cp311-cp311-win32.whl", hash = "sha256:0e32f7154299f42ae66f13488963269e5eccb8d588a65bc839ed986919fc9fac", size = 131870, upload-time = "2026-03-31T16:15:08.678Z" }, + { url = "https://files.pythonhosted.org/packages/13/26/9fe70f81d16b702f8c3a775e8731b50ad91d22dacd14c7599b60a0941cd1/orjson-3.11.8-cp311-cp311-win_amd64.whl", hash = "sha256:25e0c672a2e32348d2eb33057b41e754091f2835f87222e4675b796b92264f06", size = 127440, upload-time = "2026-03-31T16:15:09.994Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c6/b038339f4145efd2859c1ca53097a52c0bb9cbdd24f947ebe146da1ad067/orjson-3.11.8-cp311-cp311-win_arm64.whl", hash = "sha256:9185589c1f2a944c17e26c9925dcdbc2df061cc4a145395c57f0c51f9b5dbfcd", size = 127399, upload-time = "2026-03-31T16:15:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" }, + { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, ] [[package]] @@ -4410,7 +4378,7 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.1" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'darwin'", @@ -4427,59 +4395,59 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "python-dateutil", 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 = "tzdata", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, - { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, - { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, - { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, - { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, - { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, - { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, - { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, - { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, - { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, - { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, - { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, - { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, - { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, - { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, - { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, - { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, - { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, - { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, - { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, - { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, - { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, - { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, - { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, - { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, - { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, - { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, - { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, - { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, - { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, + { url = "https://files.pythonhosted.org/packages/97/35/6411db530c618e0e0005187e35aa02ce60ae4c4c4d206964a2f978217c27/pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0", size = 10326926, upload-time = "2026-03-31T06:46:08.29Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d3/b7da1d5d7dbdc5ef52ed7debd2b484313b832982266905315dad5a0bf0b1/pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c", size = 9926987, upload-time = "2026-03-31T06:46:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/52/77/9b1c2d6070b5dbe239a7bc889e21bfa58720793fb902d1e070695d87c6d0/pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb", size = 10757067, upload-time = "2026-03-31T06:46:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/20/17/ec40d981705654853726e7ac9aea9ddbb4a5d9cf54d8472222f4f3de06c2/pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76", size = 11258787, upload-time = "2026-03-31T06:46:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/90/e3/3f1126d43d3702ca8773871a81c9f15122a1f412342cc56284ffda5b1f70/pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e", size = 11771616, upload-time = "2026-03-31T06:46:20.532Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cf/0f4e268e1f5062e44a6bda9f925806721cd4c95c2b808a4c82ebe914f96b/pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa", size = 12337623, upload-time = "2026-03-31T06:46:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/97a6339859d4acb2536efb24feb6708e82f7d33b2ed7e036f2983fcced82/pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df", size = 9897372, upload-time = "2026-03-31T06:46:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/781516b808a99ddf288143cec46b342b3016c3414d137da1fdc3290d8860/pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f", size = 9154922, upload-time = "2026-03-31T06:46:30.284Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] [[package]] @@ -4502,100 +4470,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -4705,8 +4673,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", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] 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 = [ @@ -4715,26 +4683,26 @@ wheels = [ [[package]] name = "prek" -version = "0.3.4" +version = "0.3.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c6/51/2324eaad93a4b144853ca1c56da76f357d3a70c7b4fd6659e972d7bb8660/prek-0.3.4.tar.gz", hash = "sha256:56a74d02d8b7dfe3c774ecfcd8c1b4e5f1e1b84369043a8003e8e3a779fce72d", size = 356633, upload-time = "2026-02-28T03:47:13.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/20/1a964cb72582307c2f1dc7f583caab90f42810ad41551e5220592406a4c3/prek-0.3.4-py3-none-linux_armv6l.whl", hash = "sha256:c35192d6e23fe7406bd2f333d1c7dab1a4b34ab9289789f453170f33550aa74d", size = 4641915, upload-time = "2026-02-28T03:47:03.772Z" }, - { url = "https://files.pythonhosted.org/packages/c5/cb/4a21f37102bac37e415b61818344aa85de8d29a581253afa7db8c08d5a33/prek-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f784d78de72a8bbe58a5fe7bde787c364ae88f0aff5222c5c5c7287876c510a", size = 4649166, upload-time = "2026-02-28T03:47:06.164Z" }, - { url = "https://files.pythonhosted.org/packages/85/9c/a7c0d117a098d57931428bdb60fcb796e0ebc0478c59288017a2e22eca96/prek-0.3.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50a43f522625e8c968e8c9992accf9e29017abad6c782d6d176b73145ad680b7", size = 4274422, upload-time = "2026-02-28T03:46:59.356Z" }, - { url = "https://files.pythonhosted.org/packages/59/84/81d06df1724d09266df97599a02543d82fde7dfaefd192f09d9b2ccb092f/prek-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4bbb1d3912a88935f35c6ba4466b4242732e3e3a8c608623c708e83cea85de00", size = 4629873, upload-time = "2026-02-28T03:46:56.419Z" }, - { url = "https://files.pythonhosted.org/packages/09/cd/bb0aefa25cfacd8dbced75b9a9d9945707707867fa5635fb69ae1bbc2d88/prek-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca4d4134db8f6e8de3c418317becdf428957e3cab271807f475318105fd46d04", size = 4552507, upload-time = "2026-02-28T03:47:05.004Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c0/578a7af4861afb64ec81c03bfdcc1bb3341bb61f2fff8a094ecf13987a56/prek-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fb6395f6eb76133bb1e11fc718db8144522466cdc2e541d05e7813d1bbcae7d", size = 4865929, upload-time = "2026-02-28T03:47:09.231Z" }, - { url = "https://files.pythonhosted.org/packages/fc/48/f169406590028f7698ef2e1ff5bffd92ca05e017636c1163a2f5ef0f8275/prek-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae17813239ddcb4ae7b38418de4d49afff740f48f8e0556029c96f58e350412", size = 5390286, upload-time = "2026-02-28T03:47:10.796Z" }, - { url = "https://files.pythonhosted.org/packages/05/c5/98a73fec052059c3ae06ce105bef67caca42334c56d84e9ef75df72ba152/prek-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a621a690d9c127afc3d21c275030d364d1fbef3296c095068d3ae80a59546e", size = 4891028, upload-time = "2026-02-28T03:47:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b4/029966e35e59b59c142be7e1d2208ad261709ac1a66aa4a3ce33c5b9f91f/prek-0.3.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d978c31bc3b1f0b3d58895b7c6ac26f077e0ea846da54f46aeee4c7088b1b105", size = 4633986, upload-time = "2026-02-28T03:47:14.351Z" }, - { url = "https://files.pythonhosted.org/packages/1d/27/d122802555745b6940c99fcb41496001c192ddcdf56ec947ec10a0298e05/prek-0.3.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8e089a030f0a023c22a4bb2ec4ff3fcc153585d701cff67acbfca2f37e173ae", size = 4680722, upload-time = "2026-02-28T03:47:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/34/40/92318c96b3a67b4e62ed82741016ede34d97ea9579d3cc1332b167632222/prek-0.3.4-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8060c72b764f0b88112616763da9dd3a7c293e010f8520b74079893096160a2f", size = 4535623, upload-time = "2026-02-28T03:46:52.221Z" }, - { url = "https://files.pythonhosted.org/packages/df/f5/6b383d94e722637da4926b4f609d36fe432827bb6f035ad46ee02bde66b6/prek-0.3.4-py3-none-musllinux_1_1_i686.whl", hash = "sha256:65b23268456b5a763278d4e1ec532f2df33918f13ded85869a1ddff761eb9697", size = 4729879, upload-time = "2026-02-28T03:46:57.886Z" }, - { url = "https://files.pythonhosted.org/packages/79/f8/fdc705b807d813fd713ffa4f67f96741542ed1dafbb221206078c06f3df4/prek-0.3.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3975c61139c7b3200e38dc3955e050b0f2615701d3deb9715696a902e850509e", size = 5001569, upload-time = "2026-02-28T03:47:00.892Z" }, - { url = "https://files.pythonhosted.org/packages/84/92/b007a41f58e8192a1e611a21b396ad870d51d7873b7af12068ebae7fc15f/prek-0.3.4-py3-none-win32.whl", hash = "sha256:37449ae82f4dc08b72e542401e3d7318f05d1163e87c31ab260a40f425d6516e", size = 4297057, upload-time = "2026-02-28T03:47:02.219Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dc/bcb02de9b11461e8e0c7d3c8fdf8cfa15ac6efe73472a4375549ba5defd2/prek-0.3.4-py3-none-win_amd64.whl", hash = "sha256:60e9aa86ca65de963510ae28c5d94b9d7a97bcbaa6e4cdb5bf5083ed4c45dc71", size = 4655174, upload-time = "2026-02-28T03:46:53.749Z" }, - { url = "https://files.pythonhosted.org/packages/0b/86/98f5598569f4cd3de7161e266fab6a8981e65555f79d4704810c1502ad0a/prek-0.3.4-py3-none-win_arm64.whl", hash = "sha256:486bdae8f4512d3b4f6eb61b83e5b7595da2adca385af4b2b7823c0ab38d1827", size = 4367817, upload-time = "2026-02-28T03:46:55.264Z" }, + { url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" }, + { url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" }, + { url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" }, + { url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" }, + { url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" }, + { url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" }, ] [[package]] @@ -4853,14 +4821,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.27.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, ] [[package]] @@ -5146,11 +5114,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -5275,16 +5243,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -5373,7 +5341,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", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] 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 = [ @@ -5483,7 +5451,7 @@ 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.2.6", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, @@ -5514,7 +5482,7 @@ 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.2.6", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, @@ -5542,128 +5510,128 @@ wheels = [ [[package]] name = "regex" -version = "2026.2.28" +version = "2026.3.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/93/5ab3e899c47fa7994e524447135a71cd121685a35c8fe35029005f8b236f/regex-2026.3.32.tar.gz", hash = "sha256:f1574566457161678297a116fa5d1556c5a4159d64c5ff7c760e7c564bf66f16", size = 415605, upload-time = "2026-03-28T21:49:22.012Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/b8/845a927e078f5e5cc55d29f57becbfde0003d52806544531ab3f2da4503c/regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d", size = 488461, upload-time = "2026-02-28T02:15:48.405Z" }, - { url = "https://files.pythonhosted.org/packages/32/f9/8a0034716684e38a729210ded6222249f29978b24b684f448162ef21f204/regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8", size = 290774, upload-time = "2026-02-28T02:15:51.738Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ba/b27feefffbb199528dd32667cd172ed484d9c197618c575f01217fbe6103/regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5", size = 288737, upload-time = "2026-02-28T02:15:53.534Z" }, - { url = "https://files.pythonhosted.org/packages/18/c5/65379448ca3cbfe774fcc33774dc8295b1ee97dc3237ae3d3c7b27423c9d/regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb", size = 782675, upload-time = "2026-02-28T02:15:55.488Z" }, - { url = "https://files.pythonhosted.org/packages/aa/30/6fa55bef48090f900fbd4649333791fc3e6467380b9e775e741beeb3231f/regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359", size = 850514, upload-time = "2026-02-28T02:15:57.509Z" }, - { url = "https://files.pythonhosted.org/packages/a9/28/9ca180fb3787a54150209754ac06a42409913571fa94994f340b3bba4e1e/regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27", size = 896612, upload-time = "2026-02-28T02:15:59.682Z" }, - { url = "https://files.pythonhosted.org/packages/46/b5/f30d7d3936d6deecc3ea7bea4f7d3c5ee5124e7c8de372226e436b330a55/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692", size = 791691, upload-time = "2026-02-28T02:16:01.752Z" }, - { url = "https://files.pythonhosted.org/packages/f5/34/96631bcf446a56ba0b2a7f684358a76855dfe315b7c2f89b35388494ede0/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c", size = 783111, upload-time = "2026-02-28T02:16:03.651Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/f95cb7a85fe284d41cd2f3625e0f2ae30172b55dfd2af1d9b4eaef6259d7/regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d", size = 767512, upload-time = "2026-02-28T02:16:05.616Z" }, - { url = "https://files.pythonhosted.org/packages/3d/af/a650f64a79c02a97f73f64d4e7fc4cc1984e64affab14075e7c1f9a2db34/regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318", size = 773920, upload-time = "2026-02-28T02:16:08.325Z" }, - { url = "https://files.pythonhosted.org/packages/72/f8/3f9c2c2af37aedb3f5a1e7227f81bea065028785260d9cacc488e43e6997/regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b", size = 846681, upload-time = "2026-02-28T02:16:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/8db04a334571359f4d127d8f89550917ec6561a2fddfd69cd91402b47482/regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e", size = 755565, upload-time = "2026-02-28T02:16:11.972Z" }, - { url = "https://files.pythonhosted.org/packages/da/bc/91c22f384d79324121b134c267a86ca90d11f8016aafb1dc5bee05890ee3/regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e", size = 835789, upload-time = "2026-02-28T02:16:14.036Z" }, - { url = "https://files.pythonhosted.org/packages/46/a7/4cc94fd3af01dcfdf5a9ed75c8e15fd80fcd62cc46da7592b1749e9c35db/regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451", size = 780094, upload-time = "2026-02-28T02:16:15.468Z" }, - { url = "https://files.pythonhosted.org/packages/3c/21/e5a38f420af3c77cab4a65f0c3a55ec02ac9babf04479cfd282d356988a6/regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a", size = 266025, upload-time = "2026-02-28T02:16:16.828Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0a/205c4c1466a36e04d90afcd01d8908bac327673050c7fe316b2416d99d3d/regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5", size = 277965, upload-time = "2026-02-28T02:16:18.752Z" }, - { url = "https://files.pythonhosted.org/packages/c3/4d/29b58172f954b6ec2c5ed28529a65e9026ab96b4b7016bcd3858f1c31d3c/regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff", size = 270336, upload-time = "2026-02-28T02:16:20.735Z" }, - { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, - { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, - { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, - { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, - { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, - { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, - { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, - { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, - { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/59/68691428851cf9c9c3707217ab1d9b47cfeec9d153a49919e6c368b9e926/regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15", size = 266033, upload-time = "2026-02-28T02:16:45.094Z" }, - { url = "https://files.pythonhosted.org/packages/42/8b/1483de1c57024e89296cbcceb9cccb3f625d416ddb46e570be185c9b05a9/regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61", size = 277978, upload-time = "2026-02-28T02:16:46.75Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/abec45dc6e7252e3dbc797120496e43bb5730a7abf0d9cb69340696a2f2d/regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a", size = 270340, upload-time = "2026-02-28T02:16:48.626Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, - { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, - { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, - { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, - { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, - { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, - { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, - { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, - { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, - { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, - { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, - { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, - { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, - { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, - { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, - { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, - { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, - { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, - { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, - { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, - { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, - { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, - { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, - { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, - { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, - { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, - { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, - { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, - { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, - { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, - { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, - { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, - { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, - { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, - { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, - { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, - { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, - { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, - { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/ae29a505fdfcec85978f35d30e6de7c0ae37eaf7c287f6e88abd04be27b3/regex-2026.3.32-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:462a041d2160090553572f6bb0be417ab9bb912a08de54cb692829c871ee88c1", size = 489575, upload-time = "2026-03-28T21:45:27.167Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fd/7a56c6a86213e321a309161673667091991630287d7490c5e9ec3db29607/regex-2026.3.32-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c6f6b027d10f84bfe65049028892b5740878edd9eae5fea0d1710b09b1d257", size = 291288, upload-time = "2026-03-28T21:45:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/ac2b481011b23f79994d4d80df03d9feccb64fbfc7bbe8dad2c3e8efc50c/regex-2026.3.32-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:879ae91f2928a13f01a55cfa168acedd2b02b11b4cd8b5bb9223e8cde777ca52", size = 289336, upload-time = "2026-03-28T21:45:32.631Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/cf7dfef7a4182e84acbe8919ce7ff50e3545007c2743219e92271b2fbc1c/regex-2026.3.32-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:887a9fa74418d74d645281ee0edcf60694053bd1bc2ebc49eb5e66bfffc6d107", size = 786358, upload-time = "2026-03-28T21:45:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/fb/cb/42bfeb4597206e3171e70c973ca1d39190b48f6cda7546c25f9cb283285f/regex-2026.3.32-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d571f0b2eec3513734ea31a16ce0f7840c0b85a98e7edfa0e328ed144f9ef78f", size = 854179, upload-time = "2026-03-28T21:45:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/90/d8/9f4a7d7edffe7117de23b94696c52065b68e70267d71576d74429d598d9b/regex-2026.3.32-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ada7bd5bb6511d12177a7b00416ce55caee49fbf8c268f26b909497b534cacb", size = 898810, upload-time = "2026-03-28T21:45:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/80335c06ddf7fd7a28b97402ebe1ea4fe80a3aa162fba0f7364175f625d1/regex-2026.3.32-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:918db4e34a7ef3d0beee913fa54b34231cc3424676f1c19bdb85f01828d3cd37", size = 790605, upload-time = "2026-03-28T21:45:39.207Z" }, + { url = "https://files.pythonhosted.org/packages/38/0e/91436a89c1636090903d753d90b076784b11b8c67b79b3bde9851a45c4d7/regex-2026.3.32-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:69a847a6ffaa86e8af7b9e7037606e05a6f663deec516ad851e8e05d9908d16a", size = 786550, upload-time = "2026-03-28T21:45:40.993Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fc/ea7364b5e9abd220cebf547f2f8a42044878e9d8b02b3a652f8b807c0cbc/regex-2026.3.32-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c8d402ea3dfe674288fe3962016affd33b5b27213d2b5db1823ffa4de524c57", size = 770223, upload-time = "2026-03-28T21:45:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/aff4ad741e914cc493e7500431cdf14e51bc808b14f1f205469d353a970b/regex-2026.3.32-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6b39a2cc5625bbc4fda18919a891eab9aab934eecf83660a90ce20c53621a9a", size = 774436, upload-time = "2026-03-28T21:45:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e7/060779f504c92320f75b90caab4e57324816020986c27f57414b0a1ebcc9/regex-2026.3.32-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f7cc00089b4c21847852c0ad76fb3680f9833b855a0d30bcec94211c435bff6b", size = 849400, upload-time = "2026-03-28T21:45:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/6544b27f70bfd14e9c50ff5527027acc9b8f9830d352a746f843da7b0627/regex-2026.3.32-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd03e38068faeef937cc6761a250a4aaa015564bd0d61481fefcf15586d31825", size = 757934, upload-time = "2026-03-28T21:45:47.962Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6f/abf2234b3f51da1e693f13bb85e7dbb3bbdd07c04e12e0e105b9bc6006a6/regex-2026.3.32-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e006ea703d5c0f3d112b51ba18af73b58209b954acfe3d8da42eacc9a00e4be6", size = 838479, upload-time = "2026-03-28T21:45:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/653f43c3a3643fd221bfaf61ed4a4c8f0ccc25e31a8faa8f1558a892c22c/regex-2026.3.32-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6980ceb5c1049d4878632f08ba0bf7234c30e741b0dc9081da0f86eca13189d3", size = 778478, upload-time = "2026-03-28T21:45:51.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/5e6bd702d7efc3f2a29bf65dfa46f5159653b3c6f846ddf693e1a7f9a739/regex-2026.3.32-cp310-cp310-win32.whl", hash = "sha256:6128dd0793a87287ea1d8bf16b4250dd96316c464ee15953d5b98875a284d41e", size = 266343, upload-time = "2026-03-28T21:45:53.548Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/39d04329e858956d2db1d08a10f02be8f6837c964663513ac4393158bef9/regex-2026.3.32-cp310-cp310-win_amd64.whl", hash = "sha256:5aa78c857c1731bdd9863923ffadc816d823edf475c7db6d230c28b53b7bdb5e", size = 278632, upload-time = "2026-03-28T21:45:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d8/c7e9ff3c2648408f4cda7224e195ad7a0d68724225d8d9a55eca9055504f/regex-2026.3.32-cp310-cp310-win_arm64.whl", hash = "sha256:34c905a721ddee0f84c99e3e3b59dd4a5564a6fe338222bc89dd4d4df166115c", size = 270593, upload-time = "2026-03-28T21:45:56.994Z" }, + { url = "https://files.pythonhosted.org/packages/92/c1/c68163a6ce455996db71e249a65234b1c9f79a914ea2108c6c9af9e1812a/regex-2026.3.32-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d7855f5e59fcf91d0c9f4a51dc5d8847813832a2230c3e8e35912ccf20baaa2", size = 489568, upload-time = "2026-03-28T21:45:58.791Z" }, + { url = "https://files.pythonhosted.org/packages/96/9c/0bdd47733b832b5caa11e63df14dccdb311b41ab33c1221e249af4421f8f/regex-2026.3.32-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:18eb45f711e942c27dbed4109830bd070d8d618e008d0db39705f3f57070a4c6", size = 291287, upload-time = "2026-03-28T21:46:00.46Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ff/1977a595f15f8dc355f9cebd875dab67f3faeca1f36b905fe53305bbcaed/regex-2026.3.32-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed3b8281c5d0944d939c82db4ec2300409dd69ee087f7a75a94f2e301e855fb4", size = 289325, upload-time = "2026-03-28T21:46:02.285Z" }, + { url = "https://files.pythonhosted.org/packages/0a/68/dfa21aef5af4a144702befeb5ff20ea9f9fbe40a4dfd08d56148b5b48b0a/regex-2026.3.32-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad5c53f2e8fcae9144009435ebe3d9832003508cf8935c04542a1b3b8deefa15", size = 790898, upload-time = "2026-03-28T21:46:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/36/26/9424e43e0e31ac3ce1ba0e7232ee91e113a04a579c53331bc0f16a4a5bf7/regex-2026.3.32-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70c634e39c5cda0da05c93d6747fdc957599f7743543662b6dbabdd8d3ba8a96", size = 862462, upload-time = "2026-03-28T21:46:05.923Z" }, + { url = "https://files.pythonhosted.org/packages/63/a8/06573154ac891c6b55b74a88e0fb7c10081c20916b82dd0abc8cef938e13/regex-2026.3.32-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e0f6648fd48f4c73d801c55ab976cd602e2da87de99c07bff005b131f269c6a", size = 906522, upload-time = "2026-03-28T21:46:07.988Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/46673bb18448c51222c6272c850484a0092f364fae8d0315be9aa1e4baa7/regex-2026.3.32-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5e0fdb5744caf1036dec5510f543164f2144cb64932251f6dfd42fa872b7f9c", size = 798289, upload-time = "2026-03-28T21:46:09.959Z" }, + { url = "https://files.pythonhosted.org/packages/4d/cb/804f1bd5ff08687258e6a92b040aba9b770e626b8d3ba21fffdfa21db2db/regex-2026.3.32-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dab4178a0bc1ef13178832b12db7bc7f562e8f028b2b5be186e370090dc50652", size = 774823, upload-time = "2026-03-28T21:46:12.049Z" }, + { url = "https://files.pythonhosted.org/packages/e5/94/28a58258f8d822fb949c8ff87fc7e5f2a346922360ec084c193b3c95e51c/regex-2026.3.32-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f95bd07f301135771559101c060f558e2cf896c7df00bec050ca7f93bf11585a", size = 781381, upload-time = "2026-03-28T21:46:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f3/71e69dbe0543586a3e3532cf36e8c9b38d6d93033161a9799c1e9090eb78/regex-2026.3.32-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2dcca2bceb823c9cc610e57b86a265d7ffc30e9fe98548c609eba8bd3c0c2488", size = 855968, upload-time = "2026-03-28T21:46:15.762Z" }, + { url = "https://files.pythonhosted.org/packages/6d/99/850feec404a02b62e048718ec1b4b98b5c3848cd9ca2316d0bdb65a53f6a/regex-2026.3.32-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:567b57eb987547a23306444e4f6f85d4314f83e65c71d320d898aa7550550443", size = 762785, upload-time = "2026-03-28T21:46:17.394Z" }, + { url = "https://files.pythonhosted.org/packages/40/04/808ab0462a2d19b295a3b42134f5183692f798addfe6a8b6aa5f7c7a35b2/regex-2026.3.32-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b6acb765e7c1f2fa08ac9057a33595e26104d7d67046becae184a8f100932dd9", size = 845797, upload-time = "2026-03-28T21:46:19.269Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/8afcf0fd4bd55440b48442c86cddfe61b0d21c92d96e384c0c47d769f4c3/regex-2026.3.32-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1ed17104d1be7f807fdec35ec99777168dd793a09510d753f8710590ba54cdd", size = 785200, upload-time = "2026-03-28T21:46:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/23d992ab4115456fec520d6c3aae39e0e33739b244ddb39aa4102a0f7ef0/regex-2026.3.32-cp311-cp311-win32.whl", hash = "sha256:c60f1de066eb5a0fd8ee5974de4194bb1c2e7692941458807162ffbc39887303", size = 266351, upload-time = "2026-03-28T21:46:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/62/74/27c3cdb3a3fbbf67f7231b872877416ec817ae84271573d2fd14bf8723d3/regex-2026.3.32-cp311-cp311-win_amd64.whl", hash = "sha256:8fe14e24124ef41220e5992a0f09432f890037df6f93fd3d6b7a0feff2db16b2", size = 278639, upload-time = "2026-03-28T21:46:24.016Z" }, + { url = "https://files.pythonhosted.org/packages/0a/12/6a67bd509f38aec021d63096dbc884f39473e92adeb1e35d6fb6d89cbd59/regex-2026.3.32-cp311-cp311-win_arm64.whl", hash = "sha256:ded4fc0edf3de792850cb8b04bbf3c5bd725eeaf9df4c27aad510f6eed9c4e19", size = 270594, upload-time = "2026-03-28T21:46:25.857Z" }, + { url = "https://files.pythonhosted.org/packages/38/94/69492c45b0e61b027109d8433a5c3d4f7a90709184c057c7cfc60acb1bfa/regex-2026.3.32-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ad8d372587e659940568afd009afeb72be939c769c552c9b28773d0337251391", size = 490572, upload-time = "2026-03-28T21:46:28.031Z" }, + { url = "https://files.pythonhosted.org/packages/92/0a/7dcffeebe0fcac45a1f9caf80712002d3cbd66d7d69d719315ee142b280f/regex-2026.3.32-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f5747501b69299c6b0b047853771e4ed390510bada68cb16da9c9c2078343f7", size = 292078, upload-time = "2026-03-28T21:46:29.789Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ec/988486058ef49eb931476419bae00f164c4ceb44787c45dc7a54b7de0ea4/regex-2026.3.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db976be51375bca900e008941639448d148c655c9545071965d0571ecc04f5d0", size = 289786, upload-time = "2026-03-28T21:46:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cf/1955bb5567bc491bd63068e17f75ab0c9ff5e9d08466beec7e347f5e768d/regex-2026.3.32-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66a5083c3ffe5a5a95f8281ea47a88072d4f24001d562d1d9d28d4cdc005fec5", size = 796431, upload-time = "2026-03-28T21:46:33.101Z" }, + { url = "https://files.pythonhosted.org/packages/27/8a/67fcbca511b792107540181ee0690df6de877bfbcb41b7ecae7028025ca5/regex-2026.3.32-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e83ce8008b48762be296f1401f19afd9ea29f3d035d1974e0cecb74e9afbd1df", size = 865785, upload-time = "2026-03-28T21:46:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/0677bc44f2c28305edcabc11933777b9ad34e9e8ded7ba573d24e4bc3ee7/regex-2026.3.32-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3aa21bad31db904e0b9055e12c8282df62d43169c4a9d2929407060066ebc74", size = 913593, upload-time = "2026-03-28T21:46:36.835Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/661043d1c263b0d9d10c6ff4e9c9745f3df9641c62b51f96a3473638e7ce/regex-2026.3.32-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f54840bea73541652f1170dc63402a5b776fc851ad36a842da9e5163c1f504a0", size = 801512, upload-time = "2026-03-28T21:46:38.587Z" }, + { url = "https://files.pythonhosted.org/packages/ff/27/74c986061380e1811a46cf04cdf9c939db9f8c0e63953eddfe37ffd633ea/regex-2026.3.32-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ffbadc647325dd4e3118269bda93ded1eb5f5b0c3b7ba79a3da9fbd04f248e9", size = 776182, upload-time = "2026-03-28T21:46:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c8/d833397b70cd1bacfcdc0a611f0e2c1f5b91fee8eedd88affcee770cbbb6/regex-2026.3.32-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66d3126afe7eac41759cd5f0b3b246598086e88e70527c0d68c9e615b81771c4", size = 785837, upload-time = "2026-03-28T21:46:42.926Z" }, + { url = "https://files.pythonhosted.org/packages/e0/53/fa226b72989b5b93db6926fab5478115e085dfcf077e18d2cb386be0fd23/regex-2026.3.32-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f785f44a44702dea89b28bce5bc82552490694ce4e144e21a4f0545e364d2150", size = 860612, upload-time = "2026-03-28T21:46:44.8Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/bdd2fc0c055a1b15702bd4084829bbb6b06095f27990e5bee52b2898ea03/regex-2026.3.32-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b7836aa13721dbdef658aebd11f60d00de633a95726521860fe1f6be75fa225a", size = 765285, upload-time = "2026-03-28T21:46:46.625Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/21f5e2a35a191b27e5a47cccb3914c99e139b49b1342d3f36e64e8cc60f7/regex-2026.3.32-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5336b1506142eb0f23c96fb4a34b37c4fefd4fed2a7042069f3c8058efe17855", size = 851963, upload-time = "2026-03-28T21:46:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/f4/04ed04ebf335a44083695c22772be6a42efa31900415555563acf02cb4de/regex-2026.3.32-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b56993a7aeb4140c4770f4f7965c9e5af4f024457d06e23c01b0d47501cb18ed", size = 788332, upload-time = "2026-03-28T21:46:50.454Z" }, + { url = "https://files.pythonhosted.org/packages/21/25/5355908f479d0dc13d044f88270cdcabc8723efc12e4c2b19e5a94ff1a96/regex-2026.3.32-cp312-cp312-win32.whl", hash = "sha256:d363660f9ef8c734495598d2f3e527fb41f745c73159dc0d743402f049fb6836", size = 266847, upload-time = "2026-03-28T21:46:52.125Z" }, + { url = "https://files.pythonhosted.org/packages/00/e5/3be71c781a031db5df00735b613895ad5fdbf86c6e3bbea5fbbd7bfb5902/regex-2026.3.32-cp312-cp312-win_amd64.whl", hash = "sha256:c9f261ad3cd97257dc1d9355bfbaa7dd703e06574bffa0fa8fe1e31da915ee38", size = 278034, upload-time = "2026-03-28T21:46:54.096Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/27f1e0b1eea4faa99c66daca34130af20c44fae0237bbc98b87999dbc4a8/regex-2026.3.32-cp312-cp312-win_arm64.whl", hash = "sha256:89e50667e7e8c0e7903e4d644a2764fffe9a3a5d6578f72ab7a7b4205bf204b7", size = 270673, upload-time = "2026-03-28T21:46:56.046Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ba/9c1819f302b42b5fbd4139ead6280e9ec37d19bbe33379df0039b2a57bb4/regex-2026.3.32-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c6d9c6e783b348f719b6118bb3f187b2e138e3112576c9679eb458cc8b2e164b", size = 490394, upload-time = "2026-03-28T21:46:58.112Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/f62b0ce79eb83ca82fffea1736289d29bc24400355968301406789bcebd2/regex-2026.3.32-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f21ae18dfd15752cdd98d03cbd7a3640be826bfd58482a93f730dbd24d7b9fb", size = 291993, upload-time = "2026-03-28T21:47:00.198Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/ba0f8f81f88cd20c0b27acc123561ac5495ea33f800f0b8ebed2038b23eb/regex-2026.3.32-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:844d88509c968dd44b30daeefac72b038b1bf31ac372d5106358ab01d393c48b", size = 289618, upload-time = "2026-03-28T21:47:02.269Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0d/b47a0e68bc511c195ff129c0311a4cd79b954b8676193a9d03a97c623a91/regex-2026.3.32-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fc918cd003ba0d066bf0003deb05a259baaaab4dc9bd4f1207bbbe64224857a", size = 796427, upload-time = "2026-03-28T21:47:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/32b05aa8fde7789ba316533c0f30e87b6b5d38d6d7f8765eadc5aab84671/regex-2026.3.32-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbc458a292aee57d572075f22c035fa32969cdb7987d454e3e34d45a40a0a8b4", size = 865850, upload-time = "2026-03-28T21:47:05.982Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/828d8095501f237b83f630d4069eea8c0e5cb6a204e859cf0b67c223ce12/regex-2026.3.32-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:987cdfcfb97a249abc3601ad53c7de5c370529f1981e4c8c46793e4a1e1bfe8e", size = 913578, upload-time = "2026-03-28T21:47:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f8/acf1eb80f58852e85bd39a6ddfa78ce2243ddc8de8da7582e6ba657da593/regex-2026.3.32-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d88fa37ba5e8a80ca8d956b9ea03805cfa460223ac94b7d4854ee5e30f3173", size = 801536, upload-time = "2026-03-28T21:47:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/9f/05/986cdf8d12693451f5889aaf4ea4f65b2c49b1152ae814fa1fb75439e40b/regex-2026.3.32-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d082be64e51671dd5ee1c208c92da2ddda0f2f20d8ef387e57634f7e97b6aae", size = 776226, upload-time = "2026-03-28T21:47:12.891Z" }, + { url = "https://files.pythonhosted.org/packages/32/02/945a6a2348ca1c6608cb1747275c8affd2ccd957d4885c25218a86377912/regex-2026.3.32-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1d7fa44aece1fa02b8927441614c96520253a5cad6a96994e3a81e060feed55", size = 785933, upload-time = "2026-03-28T21:47:14.795Z" }, + { url = "https://files.pythonhosted.org/packages/53/12/c5bab6cc679ad79a45427a98c4e70809586ac963c5ad54a9217533c4763e/regex-2026.3.32-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d478a2ca902b6ef28ffc9521e5f0f728d036abe35c0b250ee8ae78cfe7c5e44e", size = 860671, upload-time = "2026-03-28T21:47:16.985Z" }, + { url = "https://files.pythonhosted.org/packages/bf/68/8d85f98c2443469facabef62b82b851d369b13f92bec2ca7a3808deaa47b/regex-2026.3.32-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2820d2231885e97aff0fcf230a19ebd5d2b5b8a1ba338c20deb34f16db1c7897", size = 765335, upload-time = "2026-03-28T21:47:18.872Z" }, + { url = "https://files.pythonhosted.org/packages/89/a7/d8a9c270916107a501fca63b748547c6c77e570d19f16a29b557ce734f3d/regex-2026.3.32-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc8ced733d6cd9af5e412f256a32f7c61cd2d7371280a65c689939ac4572499f", size = 851913, upload-time = "2026-03-28T21:47:20.793Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/03d392b26679914ccf21f83d18ad4443232d2f8c3e2c30a962d4e3918d9c/regex-2026.3.32-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:847087abe98b3c1ebf1eb49d6ef320dbba75a83ee4f83c94704580f1df007dd4", size = 788447, upload-time = "2026-03-28T21:47:22.628Z" }, + { url = "https://files.pythonhosted.org/packages/cf/df/692227d23535a50604333068b39eb262626db780ab1e1b19d83fc66853aa/regex-2026.3.32-cp313-cp313-win32.whl", hash = "sha256:d21a07edddb3e0ca12a8b8712abc8452481c3d3db19ae87fc94e9842d005964b", size = 266834, upload-time = "2026-03-28T21:47:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/13e4e56adc16ba607cffa1fe880f233eb9ded8ab8a8580619683c9e4ce48/regex-2026.3.32-cp313-cp313-win_amd64.whl", hash = "sha256:3c054e39a9f85a3d76c62a1d50c626c5e9306964eaa675c53f61ff7ec1204bbb", size = 277972, upload-time = "2026-03-28T21:47:26.627Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1c/80a86dbb2b416fec003b1801462bdcebbf1d43202ed5acb176e99c1ba369/regex-2026.3.32-cp313-cp313-win_arm64.whl", hash = "sha256:b2e9c2ea2e93223579308263f359eab8837dc340530b860cb59b713651889f14", size = 270649, upload-time = "2026-03-28T21:47:28.551Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/e38372da599dc1c39c599907ec535016d110034bd3701ce36554f59767ef/regex-2026.3.32-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5d86e3fb08c94f084a625c8dc2132a79a3a111c8bf6e2bc59351fa61753c2f6e", size = 494495, upload-time = "2026-03-28T21:47:30.642Z" }, + { url = "https://files.pythonhosted.org/packages/5f/27/6e29ece8c9ce01001ece1137fa21c8707529c2305b22828f63623b0eb262/regex-2026.3.32-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b6f366a5ef66a2df4d9e68035cfe9f0eb8473cdfb922c37fac1d169b468607b0", size = 293988, upload-time = "2026-03-28T21:47:32.553Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/8752e18bb87a2fe728b73b0f83c082eb162a470766063f8028759fb26844/regex-2026.3.32-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b8fca73e16c49dd972ce3a88278dfa5b93bf91ddef332a46e9443abe21ca2f7c", size = 292634, upload-time = "2026-03-28T21:47:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7b/d7729fe294e23e9c7c3871cb69d49059fa7d65fd11e437a2cbea43f6615d/regex-2026.3.32-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b953d9d496d19786f4d46e6ba4b386c6e493e81e40f9c5392332458183b0599d", size = 810532, upload-time = "2026-03-28T21:47:36.839Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/4dae7b000659f611b17b9c1541fba800b0569e4060debc4635ef1b23982c/regex-2026.3.32-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b565f25171e04d4fad950d1fa837133e3af6ea6f509d96166eed745eb0cf63bc", size = 871919, upload-time = "2026-03-28T21:47:39.192Z" }, + { url = "https://files.pythonhosted.org/packages/83/85/aa8ad3977b9399861db3df62b33fe5fef6932ee23a1b9f4f357f58f2094b/regex-2026.3.32-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f28eac18a8733a124444643a66ac96fef2c0ad65f50034e0a043b90333dc677f", size = 916550, upload-time = "2026-03-28T21:47:41.618Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/6379d7f5b59ff0656ba49cf666d5013ecee55e83245275b310b0ffc79143/regex-2026.3.32-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cdd508664430dd51b8888deb6c5b416d8de046b2e11837254378d31febe4a98", size = 814988, upload-time = "2026-03-28T21:47:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/2c/af/2dfddc64074bd9b70e27e170ee9db900542e2870210b489ad4471416ba86/regex-2026.3.32-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c35d097f509cf7e40d20d5bee548d35d6049b36eb9965e8d43e4659923405b9", size = 786337, upload-time = "2026-03-28T21:47:46.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2f/4eb8abd705236402b4fe0e130971634deffb1855e2028bf02a2b7c0e841c/regex-2026.3.32-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:85c9b0c131427470a6423baa0a9330be6fd8c3630cc3ee6fdee03360724cbec5", size = 800029, upload-time = "2026-03-28T21:47:48.356Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2c/77d9ca2c9df483b51b4b1291c96d79c9ae301077841c4db39bc822f6b4c6/regex-2026.3.32-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e50af656c15e2723eeb7279c0837e07accc594b95ec18b86821a4d44b51b24bf", size = 865843, upload-time = "2026-03-28T21:47:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/306f477a509f4eed699071b1f031d89edd5a2b5fa28c8ede5b2638eaba82/regex-2026.3.32-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4bc32b4dbdb4f9f300cf9f38f8ea2ce9511a068ffaa45ac1373ee7a943f1d810", size = 772473, upload-time = "2026-03-28T21:47:52.771Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f6/54bd83ec46ac037de2beb049afc9dd5d2769c6ecaadf7856254ce610e62a/regex-2026.3.32-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3e5d1802cba785210a4a800e63fcee7a228649a880f3bf7f2aadccb151a834b", size = 856805, upload-time = "2026-03-28T21:47:55.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/ee0e7d14de1fc6582d5782f072db6c61465a38a4142f88e175dda494b536/regex-2026.3.32-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ef250a3f5e93182193f5c927c5e9575b2cb14b80d03e258bc0b89cc5de076b60", size = 801875, upload-time = "2026-03-28T21:47:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/8a/06/0fa9daca59d07b6aabd8e0468d3b86fd578576a157206fbcddbfc2298f7d/regex-2026.3.32-cp313-cp313t-win32.whl", hash = "sha256:9cf7036dfa2370ccc8651521fcbb40391974841119e9982fa312b552929e6c85", size = 269892, upload-time = "2026-03-28T21:47:59.674Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/77f16b5ad9f10ca574f03d84a354b359b0ac33f85054f2f2daafc9f7b807/regex-2026.3.32-cp313-cp313t-win_amd64.whl", hash = "sha256:c940e00e8d3d10932c929d4b8657c2ea47d2560f31874c3e174c0d3488e8b865", size = 281318, upload-time = "2026-03-28T21:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/db4446faaea8d01c8315c9c89c7dc6abbb3305e8e712e9b23936095c4d58/regex-2026.3.32-cp313-cp313t-win_arm64.whl", hash = "sha256:ace48c5e157c1e58b7de633c5e257285ce85e567ac500c833349c363b3df69d4", size = 272366, upload-time = "2026-03-28T21:48:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/32/68/ff024bf6131b7446a791a636dbbb7fa732d586f33b276d84b3460ea49393/regex-2026.3.32-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166", size = 490430, upload-time = "2026-03-28T21:48:05.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/039d9164817ee298f2a2d0246001afe662241dcbec0eedd1fe03e2a2555e/regex-2026.3.32-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c", size = 291948, upload-time = "2026-03-28T21:48:07.666Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/77f684d90ffe3e99b828d3cabb87a0f1601d2b9decd1333ff345809b1d02/regex-2026.3.32-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc", size = 289786, upload-time = "2026-03-28T21:48:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/bd76069a0304e924682b2efd8683a01617a7e1da9b651af73039d8da76a4/regex-2026.3.32-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad", size = 796672, upload-time = "2026-03-28T21:48:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/c2d7d9a5671e111a2c16d57e0cb03e1ce35b28a115901590528aa928bb5b/regex-2026.3.32-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4", size = 866556, upload-time = "2026-03-28T21:48:14.081Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b9/9921a31931d0bc3416ac30205471e0e2ed60dcbd16fc922bbd69b427322b/regex-2026.3.32-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041", size = 912787, upload-time = "2026-03-28T21:48:16.548Z" }, + { url = "https://files.pythonhosted.org/packages/41/ab/2c1bc8ab99f63cdabdbc7823af8f4cfcd6ddbb2babf01861826c3f1ad44d/regex-2026.3.32-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929", size = 800879, upload-time = "2026-03-28T21:48:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/0be716eb2c0b2ae3a439e44432534e82b2f81848af64cb21c0473ad8ae46/regex-2026.3.32-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff", size = 776332, upload-time = "2026-03-28T21:48:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/114a61bd25dec7d1070930eaef82aadf9b05961a37629e7cca7bc3fc2257/regex-2026.3.32-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194", size = 786384, upload-time = "2026-03-28T21:48:23.277Z" }, + { url = "https://files.pythonhosted.org/packages/0c/78/be0a6531f8db426e8e60d6356aeef8e9cc3f541655a648c4968b63c87a88/regex-2026.3.32-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9", size = 861381, upload-time = "2026-03-28T21:48:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/45/b1/e5076fbe45b8fb39672584b1b606d512f5bd3a43155be68a95f6b88c1fc5/regex-2026.3.32-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249", size = 765434, upload-time = "2026-03-28T21:48:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/da/fd65d68b897f8b52b1390d20d776fa753582484724a9cb4f4c26de657ae5/regex-2026.3.32-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c", size = 851501, upload-time = "2026-03-28T21:48:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d6/1e9c991c32022a9312e9124cc974961b3a2501338de2cd1cce75a3612d7a/regex-2026.3.32-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298", size = 788076, upload-time = "2026-03-28T21:48:32.025Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5b/b23c72f6d607cbb24ef42acf0c7c2ef4eee1377a9f7ba43b312f889edfbb/regex-2026.3.32-cp314-cp314-win32.whl", hash = "sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61", size = 272255, upload-time = "2026-03-28T21:48:34.355Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ec/32bbcc42366097a8cea2c481e02964be6c6fa5ccfb0fa9581686af0bec5f/regex-2026.3.32-cp314-cp314-win_amd64.whl", hash = "sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d", size = 281160, upload-time = "2026-03-28T21:48:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/89038a028cb68e719fa03ab1ad603649fc199bcda12270d2ac7b471b8f5d/regex-2026.3.32-cp314-cp314-win_arm64.whl", hash = "sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51", size = 273688, upload-time = "2026-03-28T21:48:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/87caccd608837a1fa4f8c7edc48e206103452b9bbc94fc724fa39340e807/regex-2026.3.32-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc", size = 494506, upload-time = "2026-03-28T21:48:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/16/53/a922e6b24694d70bdd68fc3fd076950e15b1b418cff9d2cc362b3968d86f/regex-2026.3.32-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0", size = 293986, upload-time = "2026-03-28T21:48:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/60/e4/0cb32203c1aebad0577fcd5b9af1fe764869e617d5234bc6a0ad284299ea/regex-2026.3.32-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1", size = 292677, upload-time = "2026-03-28T21:48:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/5006b70291469d4174dd66ad162802e2f68419c0f2a7952d0c76c1288cfa/regex-2026.3.32-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88", size = 810661, upload-time = "2026-03-28T21:48:48.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9b/438763a20d22cd1f65f95c8f030dd25df2d80a941068a891d21a5f240456/regex-2026.3.32-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3", size = 872156, upload-time = "2026-03-28T21:48:50.739Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5b/1341287887ac982ed9f5f60125e440513ffe354aa7e3681940495af7c12a/regex-2026.3.32-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b", size = 916749, upload-time = "2026-03-28T21:48:53.57Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/1d2b48b8e94debfffc6fefb84d2a86a178cc208652a1d6493d5f29821c70/regex-2026.3.32-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327", size = 814788, upload-time = "2026-03-28T21:48:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d9/7dacb34c43adaeb954518d851f3e5d3ce495ac00a9d6010e3b4b59917c4a/regex-2026.3.32-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5", size = 786594, upload-time = "2026-03-28T21:48:58.404Z" }, + { url = "https://files.pythonhosted.org/packages/ea/72/28295068c92dbd6d3ce4fd22554345cf504e957cc57dadeda4a64fa86a57/regex-2026.3.32-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745", size = 800167, upload-time = "2026-03-28T21:49:01.226Z" }, + { url = "https://files.pythonhosted.org/packages/ca/17/b10745adeca5b8d52da050e7c746137f5d01dabc6dbbe6e8d9d821dc65c1/regex-2026.3.32-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1", size = 865906, upload-time = "2026-03-28T21:49:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/45/9d/1acbcce765044ac0c87f453f4876e0897f7a61c10315262f960184310798/regex-2026.3.32-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c", size = 772642, upload-time = "2026-03-28T21:49:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/24/41/1ef8b4811355ad7b9d7579d3aeca00f18b7bc043ace26c8c609b9287346d/regex-2026.3.32-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27", size = 856927, upload-time = "2026-03-28T21:49:09.373Z" }, + { url = "https://files.pythonhosted.org/packages/97/b1/0dc1d361be80ec1b8b707ada041090181133a7a29d438e432260a4b26f9a/regex-2026.3.32-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac", size = 801910, upload-time = "2026-03-28T21:49:11.818Z" }, + { url = "https://files.pythonhosted.org/packages/b5/db/1a23f767fa250844772a9464306d34e0fafe2c317303b88a1415096b6324/regex-2026.3.32-cp314-cp314t-win32.whl", hash = "sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba", size = 275714, upload-time = "2026-03-28T21:49:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2b/616d31b125ca76079d74d6b1d84ec0860ffdb41c379151135d06e35a8633/regex-2026.3.32-cp314-cp314t-win_amd64.whl", hash = "sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6", size = 285722, upload-time = "2026-03-28T21:49:16.642Z" }, + { url = "https://files.pythonhosted.org/packages/7e/91/043d9a00d6123c5fa22a3dc96b10445ce434a8110e1d5e53efb01f243c8b/regex-2026.3.32-cp314-cp314t-win_arm64.whl", hash = "sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9", size = 275700, upload-time = "2026-03-28T21:49:19.348Z" }, ] [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -5671,22 +5639,23 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] name = "rich" -version = "13.7.1" +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 = "typing-extensions", 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')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/01/c954e134dc440ab5f96952fe52b4fdc64225530320a910473c1fe270d9aa/rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432", size = 221248, upload-time = "2024-02-28T14:51:19.472Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/87/67/a37f6214d0e9fe57f6ae54b2956d550ca8365857f42a1ce0392bb21d9410/rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", size = 240681, upload-time = "2024-02-28T14:51:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, ] [[package]] @@ -5827,27 +5796,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" +version = "0.15.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, - { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -5931,7 +5900,7 @@ resolution-markers = [ ] dependencies = [ { name = "joblib", 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, 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 = "threadpoolctl", 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')" }, ] @@ -6055,7 +6024,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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')" }, ] 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 = [ @@ -6128,9 +6097,9 @@ 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.2.6", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, 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 = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, 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 = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, 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 = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, 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 = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, 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')" }, ] 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 = [ @@ -6337,15 +6306,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.3.3" +version = "3.3.4" 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, ] [[package]] @@ -6397,7 +6366,7 @@ dependencies = [ { 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", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, 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 = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, 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 = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, @@ -6545,56 +6514,56 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -6635,11 +6604,11 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20260305" +version = "2.9.0.20260402" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/30/c5d9efbff5422b20c9551dc5af237d1ab0c3d33729a9b3239a876ca47dd4/types_python_dateutil-2.9.0.20260402.tar.gz", hash = "sha256:a980142b9966713acb382c467e35c5cc4208a2f91b10b8d785a0ae6765df6c0b", size = 16941, upload-time = "2026-04-02T04:18:35.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/fe753bf8329c8c3c1addcba1d2bf716c33898216757abb24f8b80f82d040/types_python_dateutil-2.9.0.20260402-py3-none-any.whl", hash = "sha256:7827e6a9c93587cc18e766944254d1351a2396262e4abe1510cbbd7601c5e01f", size = 18436, upload-time = "2026-04-02T04:18:34.806Z" }, ] [[package]] @@ -6653,14 +6622,14 @@ wheels = [ [[package]] name = "types-requests" -version = "2.32.4.20260107" +version = "2.33.0.20260402" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/7b/a06527d20af1441d813360b8e0ce152a75b7d8e4aab7c7d0a156f405d7ec/types_requests-2.33.0.20260402.tar.gz", hash = "sha256:1bdd3ada9b869741c5c4b887d2c8b4e38284a1449751823b5ebbccba3eefd9da", size = 23851, upload-time = "2026-04-02T04:19:55.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, + { url = "https://files.pythonhosted.org/packages/51/65/3853bb6bac5ae789dc7e28781154705c27859eccc8e46282c3f36780f5f5/types_requests-2.33.0.20260402-py3-none-any.whl", hash = "sha256:c98372d7124dd5d10af815ee25c013897592ff92af27b27e22c98984102c3254", size = 20739, upload-time = "2026-04-02T04:19:54.955Z" }, ] [[package]] @@ -6716,27 +6685,28 @@ wheels = [ [[package]] name = "uv" -version = "0.10.9" +version = "0.11.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/59/235fa08a6b56de82a45a385dc2bf724502f720f0a9692a1a8cb24aab3e6f/uv-0.10.9.tar.gz", hash = "sha256:31e76ae92e70fec47c3efab0c8094035ad7a578454482415b496fa39fc4d685c", size = 3945685, upload-time = "2026-03-06T21:21:16.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/ed/f11c558e8d2e02fba6057dacd9e92a71557359a80bd5355452310b89f40f/uv-0.11.3.tar.gz", hash = "sha256:6a6fcaf1fec28bbbdf0dfc5a0a6e34be4cea08c6287334b08c24cf187300f20d", size = 4027684, upload-time = "2026-04-01T21:47:22.096Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/6d/f87f1530d5db4132776d49dddd88b1c77bc08fa7b32bf585b366204e6fc2/uv-0.10.9-py3-none-linux_armv6l.whl", hash = "sha256:0649f83fa0f44f18627c00b2a9a60e5c3486a34799b2c874f2b3945b76048a67", size = 22617914, upload-time = "2026-03-06T21:20:48.282Z" }, - { url = "https://files.pythonhosted.org/packages/6f/34/2e5cd576d312eb1131b615f49ee95ff6efb740965324843617adae729cf2/uv-0.10.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:880dd4cffe4bd184e8871ddf4c7d3c3b042e1f16d2682310644aa8d61eaea3e6", size = 21778779, upload-time = "2026-03-06T21:21:01.804Z" }, - { url = "https://files.pythonhosted.org/packages/89/35/684f641de4de2b20db7d2163c735b2bb211e3b3c84c241706d6448e5e868/uv-0.10.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7a784254380552398a6baf4149faf5b31a4003275f685c28421cf8197178a08", size = 20384301, upload-time = "2026-03-06T21:21:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5c/7170cfd1b4af09b435abc5a89ff315af130cf4a5082e5eb1206ee46bba67/uv-0.10.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5ea0e8598fa012cfa4480ecad4d112bc70f514157c3cc1555a7611c7b6b1ab0a", size = 22226893, upload-time = "2026-03-06T21:20:50.902Z" }, - { url = "https://files.pythonhosted.org/packages/43/5c/68a17934dc8a2897fd7928b1c03c965373a820dc182aad96f1be6cce33a1/uv-0.10.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2d6b5367e9bf87eca51c0f2ecda26a1ff931e41409977b4f0a420de2f3e617cf", size = 22233832, upload-time = "2026-03-06T21:21:11.748Z" }, - { url = "https://files.pythonhosted.org/packages/00/10/d262172ac59b669ca9c006bcbdb49c1a168cc314a5de576a4bb476dfab4c/uv-0.10.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd04e34db27f9a1d5a0871980edc9f910bb11afbc4abca8234d5a363cbe63c04", size = 22192193, upload-time = "2026-03-06T21:20:59.48Z" }, - { url = "https://files.pythonhosted.org/packages/a2/e6/f75fef1e3e5b0cf3592a4c35ed5128164ef2e6bd6a2570a0782c0baf6d4b/uv-0.10.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:547deb57311fc64e4a6b8336228fca4cb4dcbeabdc6e85f14f7804dcd0bc8cd2", size = 23571687, upload-time = "2026-03-06T21:20:45.403Z" }, - { url = "https://files.pythonhosted.org/packages/31/28/4b1ee6f4aa0e1b935e66b6018691258d1b702ef9c5d8c71e853564ad0a3a/uv-0.10.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0091b6d0b666640d7407a433860184f77667077b73564e86d49c2a851f073a8", size = 24418225, upload-time = "2026-03-06T21:21:09.459Z" }, - { url = "https://files.pythonhosted.org/packages/39/a2/5e67987f8d55eeecca7d8f4e94ac3e973fa1e8aaf426fcb8f442e9f7e2bc/uv-0.10.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81b2286e6fd869e3507971f39d14829c03e2e31caa8ecc6347b0ffacabb95a5b", size = 23555724, upload-time = "2026-03-06T21:20:54.085Z" }, - { url = "https://files.pythonhosted.org/packages/79/34/b104c413079874493eed7bf11838b47b697cf1f0ed7e9de374ea37b4e4e0/uv-0.10.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c9d6deb30edbc22123be75479f99fb476613eaf38a8034c0e98bba24a344179", size = 23438145, upload-time = "2026-03-06T21:21:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/8a/cad762b3e9bfb961b68b2ae43a258a92b522918958954b50b09dcb14bb4e/uv-0.10.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:24b1ce6d626e06c4582946b6af07b08a032fcccd81fe54c3db3ed2d1c63a97dc", size = 22326765, upload-time = "2026-03-06T21:21:14.283Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/7e066f197f3eb8f8f71e25d703a29c89849c9c047240c1223e29bc0a37e4/uv-0.10.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fa3401780273d96a2960dbeab58452ce1b387ad8c5da25be6221c0188519e21d", size = 23215175, upload-time = "2026-03-06T21:21:29.673Z" }, - { url = "https://files.pythonhosted.org/packages/7e/06/51db93b5edb8b0202c0ec6caf3f24384f5abdfc180b6376a3710223fd56f/uv-0.10.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8f94a31832d2b4c565312ea17a71b8dd2f971e5aa570c5b796a27b2c9fcdb163", size = 22784507, upload-time = "2026-03-06T21:21:20.676Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/1db511d9259c1f32e5e094133546e5723e183a9ba2c64f7ca6156badddee/uv-0.10.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:842c39c19d9072f1ad53c71bb4ecd1c9caa311d5de9d19e09a636274a6c95e2e", size = 23660703, upload-time = "2026-03-06T21:21:06.667Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/58388abb252c7a37bc67422fce3a6b87404ea3fac44ca20132a4ba502235/uv-0.10.9-py3-none-win32.whl", hash = "sha256:ed44047c602449916ba18a8596715ef7edbbd00859f3db9eac010dc62a0edd30", size = 21524142, upload-time = "2026-03-06T21:21:18.246Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e9/adf7a12136573937d12ac189569e2e90e7fad18b458192083df6986f3013/uv-0.10.9-py3-none-win_amd64.whl", hash = "sha256:af79552276d8bd622048ab2d67ec22120a6af64d83963c46b1482218c27b571f", size = 24103389, upload-time = "2026-03-06T21:20:56.495Z" }, - { url = "https://files.pythonhosted.org/packages/5e/49/4971affd9c62d26b3ff4a84dc6432275be72d9615d95f7bb9e027beeeed8/uv-0.10.9-py3-none-win_arm64.whl", hash = "sha256:47e18a0521d76293d4f60d129f520b18bddf1976b4a47b50f0fcb04fb6a9d40f", size = 22454171, upload-time = "2026-03-06T21:21:24.596Z" }, + { url = "https://files.pythonhosted.org/packages/cb/93/4f04c49fd6046a18293de341d795ded3b9cbd95db261d687e26db0f11d1e/uv-0.11.3-py3-none-linux_armv6l.whl", hash = "sha256:deb533e780e8181e0859c68c84f546620072cd1bd827b38058cb86ebfba9bb7d", size = 23337334, upload-time = "2026-04-01T21:46:47.545Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4b/c44fd3fbc80ac2f81e2ad025d235c820aac95b228076da85be3f5d509781/uv-0.11.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d2b3b0fa1693880ca354755c216ae1c65dd938a4f1a24374d0c3f4b9538e0ee6", size = 22940169, upload-time = "2026-04-01T21:47:32.72Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/7d01be259a47d42fa9e80adcb7a829d81e7c376aa8fa1b714f31d7dfc226/uv-0.11.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71f5d0b9e73daa5d8a7e2db3fa2e22a4537d24bb4fe78130db797280280d4edc", size = 21473579, upload-time = "2026-04-01T21:47:25.063Z" }, + { url = "https://files.pythonhosted.org/packages/9a/71/fffcd890290a4639a3799cf3f3e87947c10d1b0de19eba3cf837cb418dd8/uv-0.11.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:55ba578752f29a3f2b22879b22a162edad1454e3216f3ca4694fdbd4093a6822", size = 23132691, upload-time = "2026-04-01T21:47:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7b/1ac9e1f753a19b6252434f0bbe96efdcc335cd74677f4c6f431a7c916114/uv-0.11.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3b1fe09d5e1d8e19459cd28d7825a3b66ef147b98328345bad6e17b87c4fea48", size = 22955764, upload-time = "2026-04-01T21:46:51.721Z" }, + { url = "https://files.pythonhosted.org/packages/ff/51/1a6010a681a3c3e0a8ec99737ba2d0452194dc372a5349a9267873261c02/uv-0.11.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:088165b9eed981d2c2a58566cc75dd052d613e47c65e2416842d07308f793a6f", size = 22966245, upload-time = "2026-04-01T21:47:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/38/74/1a1b0712daead7e85f56d620afe96fe166a04b615524c14027b4edd39b82/uv-0.11.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef0ae8ee2988928092616401ec7f473612b8e9589fe1567452c45dbc56840f85", size = 24623370, upload-time = "2026-04-01T21:47:03.59Z" }, + { url = "https://files.pythonhosted.org/packages/b6/62/5c3aa5e7bd2744810e50ad72a5951386ec84a513e109b1b5cb7ec442f3b6/uv-0.11.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6708827ecb846d00c5512a7e4dc751c2e27b92e9bd55a0be390561ac68930c32", size = 25142735, upload-time = "2026-04-01T21:46:55.756Z" }, + { url = "https://files.pythonhosted.org/packages/88/ab/6266a04980e0877af5518762adfe23a0c1ab0b801ae3099a2e7b74e34411/uv-0.11.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df030ea7563e99c09854e1bc82ab743dfa2d0ba18976e6861979cb40d04dba7", size = 24512083, upload-time = "2026-04-01T21:46:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/4e/be/7c66d350f833eb437f9aa0875655cc05e07b441e3f4a770f8bced56133f7/uv-0.11.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fde893b5ab9f6997fe357138e794bac09d144328052519fbbe2e6f72145e457", size = 24589293, upload-time = "2026-04-01T21:47:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/18/4f/22ada41564a8c8c36653fc86f89faae4c54a4cdd5817bda53764a3eb352d/uv-0.11.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:45006bcd9e8718248a23ab81448a5beb46a72a9dd508e3212d6f3b8c63aeb88a", size = 23214854, upload-time = "2026-04-01T21:46:59.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/18/8669840657fea9fd668739dec89643afe1061c023c1488228b02f79a2399/uv-0.11.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:089b9d338a64463956b6fee456f03f73c9a916479bdb29009600781dc1e1d2a7", size = 23914434, upload-time = "2026-04-01T21:47:29.164Z" }, + { url = "https://files.pythonhosted.org/packages/08/0d/c59f24b3a1ae5f377aa6fd9653562a0968ea6be946fe35761871a0072919/uv-0.11.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3ff461335888336467402cc5cb792c911df95dd0b52e369182cfa4c902bb21f4", size = 23971481, upload-time = "2026-04-01T21:47:48.551Z" }, + { url = "https://files.pythonhosted.org/packages/66/7d/f83ed79921310ef216ed6d73fcd3822dff4b66749054fb97e09b7bd5901e/uv-0.11.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a62e29277efd39c35caf4a0fe739c4ebeb14d4ce4f02271f3f74271d608061ff", size = 23784797, upload-time = "2026-04-01T21:47:40.588Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/3ff3539c44ca7dc2aa87b021d4a153ba6a72866daa19bf91c289e4318f95/uv-0.11.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ebccdcdebd2b288925f0f7c18c39705dc783175952eacaf94912b01d3b381b86", size = 24794606, upload-time = "2026-04-01T21:47:36.814Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/e676454bb7cc5dcf5c4637ed3ef0ff97309d84a149b832a4dea53f04c0ab/uv-0.11.3-py3-none-win32.whl", hash = "sha256:794aae3bab141eafbe37c51dc5dd0139658a755a6fa9cc74d2dbd7c71dcc4826", size = 22573432, upload-time = "2026-04-01T21:47:15.143Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a0/95d22d524bd3b4708043d65035f02fc9656e5fb6e0aaef73510313b1641b/uv-0.11.3-py3-none-win_amd64.whl", hash = "sha256:68fda574f2e5e7536a2b747dcea88329a71aad7222317e8f4717d0af8f99fbd4", size = 24969508, upload-time = "2026-04-01T21:47:19.515Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/3f0b90a06e8c4594e11f813651756d6896de6dd4461f554fd7e4984a1c4f/uv-0.11.3-py3-none-win_arm64.whl", hash = "sha256:92ffc4d521ab2c4738ef05d8ef26f2750e26d31f3ad5611cdfefc52445be9ace", size = 23488911, upload-time = "2026-04-01T21:47:52.427Z" }, ] [[package]] @@ -6992,14 +6962,14 @@ wheels = [ [[package]] name = "werkzeug" -version = "3.1.6" +version = "3.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/43/76ded108b296a49f52de6bac5192ca1c4be84e886f9b5c9ba8427d9694fd/werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351", size = 875700, upload-time = "2026-03-24T01:08:07.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b2/0bba9bbb4596d2d2f285a16c2ab04118f6b957d8441566e1abb892e6a6b2/werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", size = 226295, upload-time = "2026-03-24T01:08:06.133Z" }, ] [[package]]