mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70247bc21d | ||
|
|
289cafcf36 | ||
|
|
46326b6b93 | ||
|
|
4050107942 | ||
|
|
a12cc3878e | ||
|
|
47f5c3397f | ||
|
|
01a3c5be8a | ||
|
|
d74d26c917 | ||
|
|
72a6157c6a | ||
|
|
0ba552b84c | ||
|
|
dd1e615dad | ||
|
|
f390595188 | ||
|
|
4609535e22 |
@@ -17,7 +17,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -24,7 +24,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Node.js environment
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -37,7 +37,7 @@ runs:
|
||||
run: copilot --version && copilot -p "What can you do in one sentence?"
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
|
||||
@@ -44,9 +44,15 @@ updates:
|
||||
|
||||
# Maintain dependencies for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
# Workflow files stored in the
|
||||
# default location of `.github/workflows`
|
||||
directory: "/"
|
||||
# Cover both the standard workflow location and our composite actions.
|
||||
# With `directory: "/"` Dependabot only scans `.github/workflows/*.{yml,yaml}`
|
||||
# plus a root-level `action.yml/action.yaml`. It does NOT recurse into
|
||||
# `.github/actions/*/action.yml`, so the glob below is required to keep the
|
||||
# composite actions in `.github/actions/<name>/` up to date as well.
|
||||
# Ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#directories-or-directory--
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/*"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "sunday"
|
||||
|
||||
@@ -32,13 +32,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -64,6 +64,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
# Safe checkout: base repo only, not the untrusted PR head.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -135,12 +135,12 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
- name: Build dotnet solutions
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
# Generate test reports and check coverage
|
||||
- name: Generate test reports
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@2a82782178b2816d9d6960a7345fdd164791b323 # 5.5.3
|
||||
with:
|
||||
reports: "./TestResults/Coverage/**/*.cobertura.xml"
|
||||
targetdir: "./TestResults/Reports"
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage report artifact
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
|
||||
path: ./TestResults/Reports # Directory containing files to upload
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
|
||||
- name: Upload integration test results
|
||||
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
env:
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -366,7 +366,7 @@ jobs:
|
||||
python
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -381,7 +381,7 @@ jobs:
|
||||
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -453,7 +453,7 @@ jobs:
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -465,7 +465,7 @@ jobs:
|
||||
dotnet build ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests -c Release -f net10.0 --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
|
||||
- name: Upload functions test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dotnet-test-results-functions-net10.0-ubuntu-latest
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
@@ -560,14 +560,14 @@ jobs:
|
||||
- name: Fail workflow if tests failed
|
||||
id: check_tests_failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
id: check_tests_cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -585,7 +585,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -597,12 +597,12 @@ jobs:
|
||||
python-version: "3.13"
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
@@ -619,13 +619,13 @@ jobs:
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
- name: Upload trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dotnet-integration-test-report
|
||||
path: |
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: jitterbit/get-changed-files@v1
|
||||
uses: jitterbit/get-changed-files@b17fbb00bdc0c0f63fcf166580804b4d2cdc2a42 # v1
|
||||
continue-on-error: true
|
||||
|
||||
- name: No C# files changed
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
environment: 'integration'
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -52,13 +52,13 @@ jobs:
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # 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
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: verify-samples-results
|
||||
path: |
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Check issue author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
steps:
|
||||
# Safe checkout: base repo only.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
# Private DevFlow (maf-dashboard) checkout.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
|
||||
@@ -16,6 +16,6 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
with:
|
||||
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
name: "Issue/PR: update title"
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -19,13 +19,13 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
# check out the latest version of the code
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Checks the status of hyperlinks in all files
|
||||
- name: Run linkspector
|
||||
uses: umbrelladocs/action-linkspector@v1
|
||||
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
|
||||
with:
|
||||
reporter: local
|
||||
filter_mode: nofilter
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- name: Wait for required checks
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TIMEOUT_SECONDS: "3600"
|
||||
INTERVAL_SECONDS: "30"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/prek
|
||||
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
|
||||
- uses: j178/prek-action@v1
|
||||
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1
|
||||
name: Run Pre-commit Hooks (excluding poe-check)
|
||||
env:
|
||||
SKIP: poe-check
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Upload dependency range report
|
||||
# Always publish the report so failures are inspectable even when validation fails.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dependency-range-results
|
||||
path: python/scripts/dependencies/dependency-range-results.json
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Create issues for failed dependency candidates
|
||||
# Always process the report so failed candidates create actionable tracking issues.
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -388,7 +388,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -399,7 +399,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -443,7 +443,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
@@ -496,7 +496,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -506,12 +506,12 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
@@ -528,13 +528,13 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -558,12 +558,12 @@ jobs:
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
# Surface failing tests
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/packages/lab/**.xml
|
||||
summary: true
|
||||
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
title: OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
title: Azure OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -370,7 +370,7 @@ jobs:
|
||||
kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -379,7 +379,7 @@ jobs:
|
||||
title: Misc integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -448,7 +448,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -457,7 +457,7 @@ jobs:
|
||||
title: Functions integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
@@ -488,7 +488,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -497,7 +497,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -515,7 +515,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -524,7 +524,7 @@ jobs:
|
||||
title: Test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -549,7 +549,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -558,7 +558,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -576,7 +576,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -585,7 +585,7 @@ jobs:
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -620,7 +620,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -643,7 +643,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -652,7 +652,7 @@ jobs:
|
||||
title: Cosmos integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
@@ -680,19 +680,19 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
@@ -709,13 +709,13 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -740,13 +740,13 @@ jobs:
|
||||
- name: Fail workflow if tests failed
|
||||
id: check_tests_failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
id: check_tests_cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Build the package
|
||||
run: uv run poe --directory packages/${{ env.PACKAGE }} build
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: |
|
||||
python/dist/*
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-01-get-started
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-openai
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure --save-report --report-name 02-agents-azure
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-azure
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-anthropic
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-github-copilot
|
||||
@@ -274,7 +274,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-amazon
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -321,7 +321,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-ollama
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -363,7 +363,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry --save-report --report-name 02-agents-foundry
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-foundry
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-copilotstudio
|
||||
@@ -419,7 +419,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-custom
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -471,7 +471,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-03-workflows
|
||||
@@ -491,7 +491,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -506,7 +506,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting
|
||||
@@ -534,7 +534,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -549,7 +549,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-05-end-to-end
|
||||
@@ -574,7 +574,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -599,7 +599,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-autogen-migration
|
||||
@@ -633,7 +633,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -662,7 +662,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-semantic-kernel-migration
|
||||
@@ -690,10 +690,10 @@ jobs:
|
||||
- validate-autogen-migration
|
||||
- validate-semantic-kernel-migration
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Download all validation reports
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: validation-report-*
|
||||
path: reports/
|
||||
@@ -701,7 +701,7 @@ jobs:
|
||||
|
||||
- name: Restore validation history
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
@@ -719,13 +719,13 @@ jobs:
|
||||
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save validation history
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
|
||||
- name: Upload trend report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-trend-report
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
||||
- name: Pytest coverage comment
|
||||
id: coverageComment
|
||||
uses: MishaKav/pytest-coverage-comment@v1.6.0
|
||||
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
# in the coverage report workflow does not have access to it
|
||||
- name: Save PR number
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Check coverage threshold
|
||||
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
path: |
|
||||
python/python-coverage.xml
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
# Surface failing tests
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
<Folder Name="/Samples/02-agents/Harness/">
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console_OpenAI/Harness_Shared_Console_OpenAI.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
|
||||
@@ -192,6 +192,11 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnResponseUpdateAsync(this._ux, update, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
foreach (var observer in this._observers)
|
||||
|
||||
@@ -24,6 +24,17 @@ public abstract class ConsoleObserver
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
|
||||
/// whether it contains content. Override to inspect update-level metadata such as
|
||||
/// <see cref="AgentResponseUpdate.RawRepresentation"/> for provider-specific events.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="update">The streaming response update.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Detects and displays error/incomplete status from OpenAI Responses API streaming updates.
|
||||
/// Handles <see cref="StreamingResponseFailedUpdate"/> and <see cref="StreamingResponseIncompleteUpdate"/>
|
||||
/// which are not surfaced as <see cref="ErrorContent"/> by the chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note: <see cref="StreamingResponseErrorUpdate"/> is already handled by the SDK — it produces
|
||||
/// an <see cref="ErrorContent"/> which is displayed by <see cref="ErrorDisplayObserver"/>.
|
||||
/// This observer covers the cases where the SDK does not produce <see cref="ErrorContent"/>.
|
||||
/// </remarks>
|
||||
public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session)
|
||||
{
|
||||
// AgentResponseUpdate.RawRepresentation is the ChatResponseUpdate,
|
||||
// whose RawRepresentation is the underlying StreamingResponseUpdate.
|
||||
object? rawUpdate = (update.RawRepresentation as ChatResponseUpdate)?.RawRepresentation
|
||||
?? update.RawRepresentation;
|
||||
|
||||
switch (rawUpdate)
|
||||
{
|
||||
case StreamingResponseFailedUpdate failedUpdate:
|
||||
// Only display if the response has error details populated.
|
||||
// When error is null, a follow-up StreamingResponseErrorUpdate typically
|
||||
// carries the real error — the SDK surfaces that as ErrorContent,
|
||||
// which is displayed by ErrorDisplayObserver.
|
||||
if (failedUpdate.Response?.Error is { } error)
|
||||
{
|
||||
string errorMessage = error.Message ?? "Unknown error";
|
||||
string? errorCode = error.Code.ToString();
|
||||
string errorText = $"❌ Response failed: {errorMessage}";
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
errorText += $" (code: {errorCode})";
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -3,19 +3,18 @@
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace SampleApp;
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Displays web search activity in the scroll area. Shows search queries,
|
||||
/// page opens, and find-in-page actions as they stream in from the API.
|
||||
/// </summary>
|
||||
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
public sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private const int MaxQueryDisplayLength = 120;
|
||||
|
||||
+1
@@ -16,6 +16,7 @@
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,6 +19,7 @@ using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -107,6 +108,7 @@ await HarnessConsole.RunAgentAsync(
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
|
||||
+1
@@ -16,6 +16,7 @@
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+7
-5
@@ -16,6 +16,7 @@ using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -102,10 +103,7 @@ AIAgent parentAgent =
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
|
||||
DisableWebSearch = true,
|
||||
AIContextProviders =
|
||||
[
|
||||
new BackgroundAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
BackgroundAgents = [webSearchAgent],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
@@ -116,4 +114,8 @@ AIAgent parentAgent =
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):",
|
||||
options: new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [new OpenAIResponsesErrorObserver(), .. HarnessConsoleOptions.BuildDefaultObservers()],
|
||||
});
|
||||
|
||||
@@ -28,9 +28,7 @@ public sealed class A2AAgent : AIAgent
|
||||
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
|
||||
|
||||
private readonly IA2AClient _a2aClient;
|
||||
private readonly string? _id;
|
||||
private readonly string? _name;
|
||||
private readonly string? _description;
|
||||
private readonly A2AAgentOptions _agentOptions;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -38,17 +36,37 @@ public sealed class A2AAgent : AIAgent
|
||||
/// </summary>
|
||||
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
|
||||
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
|
||||
: this(
|
||||
a2aClient,
|
||||
new A2AAgentOptions
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = description
|
||||
},
|
||||
loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="A2AAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
|
||||
/// <param name="options">
|
||||
/// Configuration options that control the agent's identity, including its identifier, name, and description.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
|
||||
public A2AAgent(IA2AClient a2aClient, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(a2aClient);
|
||||
_ = Throw.IfNull(options);
|
||||
|
||||
this._a2aClient = a2aClient;
|
||||
this._id = id;
|
||||
this._name = name;
|
||||
this._description = description;
|
||||
this._agentOptions = options.Clone();
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
|
||||
}
|
||||
|
||||
@@ -216,13 +234,13 @@ public sealed class A2AAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string? IdCore => this._id;
|
||||
protected override string? IdCore => this._agentOptions.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._name;
|
||||
public override string? Name => this._agentOptions.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Description => this._description;
|
||||
public override string? Description => this._agentOptions.Description;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Represents configuration options for an <see cref="A2AAgent"/>, including its identifier, name, and description.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class is used to encapsulate information about an A2A agent, such as its unique
|
||||
/// identifier, display name, and a descriptive summary. It provides an alternative to passing
|
||||
/// these values as individual constructor parameters.
|
||||
/// </remarks>
|
||||
public sealed class A2AAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the agent id.
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent name.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="A2AAgentOptions"/> with the same values as this instance.
|
||||
/// </summary>
|
||||
public A2AAgentOptions Clone()
|
||||
=> new()
|
||||
{
|
||||
Id = this.Id,
|
||||
Name = this.Name,
|
||||
Description = this.Description
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
using System.Net.Http;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
@@ -36,4 +38,39 @@ public static class A2AAgentCardExtensions
|
||||
|
||||
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
|
||||
/// the corresponding values from the <see cref="AgentCard"/>.
|
||||
/// </remarks>
|
||||
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
|
||||
/// <param name="agentOptions">
|
||||
/// Configuration options that control the agent's identity. When provided, non-null values override the
|
||||
/// corresponding values from the agent card.
|
||||
/// </param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this AgentCard card, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(card);
|
||||
_ = Throw.IfNull(agentOptions);
|
||||
|
||||
var a2aClient = A2AClientFactory.Create(card, httpClient, clientOptions);
|
||||
|
||||
var mergedOptions = agentOptions.Clone();
|
||||
mergedOptions.Name ??= card.Name;
|
||||
mergedOptions.Description ??= card.Description;
|
||||
|
||||
return a2aClient.AsAIAgent(mergedOptions, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
@@ -48,4 +49,39 @@ public static class A2ACardResolverExtensions
|
||||
|
||||
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see>
|
||||
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
|
||||
/// the corresponding values from the resolved <see cref="AgentCard"/>.
|
||||
/// </remarks>
|
||||
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
|
||||
/// <param name="agentOptions">
|
||||
/// Configuration options that control the agent's identity. When provided, non-null values override the
|
||||
/// corresponding values from the resolved agent card.
|
||||
/// </param>
|
||||
/// <param name="httpClient">
|
||||
/// The <see cref="HttpClient"/> to use for HTTP requests made by the created A2A client.
|
||||
/// This is not used for fetching the agent card; the resolver uses its own configured client for that.
|
||||
/// </param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(agentOptions);
|
||||
|
||||
// Obtain the agent card from the resolver.
|
||||
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return agentCard.AsAIAgent(agentOptions, httpClient, clientOptions, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
@@ -31,10 +32,32 @@ public static class A2AClientExtensions
|
||||
/// </remarks>
|
||||
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
|
||||
new A2AAgent(client, id, name, description, loggerFactory);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
|
||||
/// <param name="options">
|
||||
/// Configuration options that control the agent's identity, including its identifier, name, and description.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this IA2AClient client, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(client);
|
||||
_ = Throw.IfNull(options);
|
||||
|
||||
return new A2AAgent(client, options, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
@@ -9,8 +10,11 @@ using Microsoft.Extensions.AI;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
|
||||
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
|
||||
/// Pipeline policy that emits the hosted-agent <c>User-Agent</c> segment
|
||||
/// (<c>"foundry-hosting/agent-framework-dotnet/{version}"</c>), matching Python's hosted
|
||||
/// contract (<c>foundry-hosting/agent-framework-python/{version}</c>, see
|
||||
/// <c>python/packages/core/agent_framework/_telemetry.py</c>: the hosted prefix is joined
|
||||
/// with the base agent-framework segment into a single combined User-Agent value).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -19,6 +23,12 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a bare <c>agent-framework-dotnet/{version}</c> segment is already present (stamped by
|
||||
/// the framework-wide <c>AgentFrameworkUserAgentPolicy</c> registered by
|
||||
/// <c>FoundryChatClient</c>), this policy <em>replaces</em> that segment with the combined
|
||||
/// hosted form so the wire never carries both forms simultaneously, preserving Python parity.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at hosted-agent resolution time via the MEAI 10.5.1
|
||||
/// <see cref="OpenAIRequestPolicies"/> hook on the agent's underlying chat client. It is only
|
||||
/// registered when an agent is resolved by the Foundry hosting layer.
|
||||
@@ -30,6 +40,12 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
|
||||
private static readonly string s_supplementValue = CreateSupplementValue();
|
||||
|
||||
/// <summary>Bare segment stamped by <c>AgentFrameworkUserAgentPolicy</c> in the non-hosted scenario; this policy upgrades it in-place when both run.</summary>
|
||||
private const string BareAgentFrameworkPrefix = "agent-framework-dotnet/";
|
||||
|
||||
/// <summary>Combined hosted segment that this policy emits. Recognized in-place so callers whose pipelines already carry a (possibly different-version) combined segment get it replaced rather than double-prefixed (Q-D fix).</summary>
|
||||
private const string CombinedHostedPrefix = "foundry-hosting/agent-framework-dotnet/";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
@@ -46,13 +62,52 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing.Contains(s_supplementValue))
|
||||
// Guard against double-append on retries or when the policy is registered on
|
||||
// multiple pipeline positions.
|
||||
if (existing!.Contains(s_supplementValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Combined-form check first: if the caller's pipeline already has
|
||||
// `foundry-hosting/agent-framework-dotnet/{version}` (with a version that differs
|
||||
// from ours — otherwise the .Contains above would have returned early), replace the
|
||||
// entire combined span in place. Without this, the bare-prefix search below would
|
||||
// match `agent-framework-dotnet/` *inside* the combined segment and produce a
|
||||
// malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` value.
|
||||
var combinedIdx = existing.IndexOf(CombinedHostedPrefix, StringComparison.Ordinal);
|
||||
if (combinedIdx >= 0)
|
||||
{
|
||||
var combinedEnd = existing.IndexOf(' ', combinedIdx);
|
||||
if (combinedEnd < 0)
|
||||
{
|
||||
combinedEnd = existing.Length;
|
||||
}
|
||||
|
||||
var replacedCombined = string.Concat(existing.AsSpan(0, combinedIdx), s_supplementValue.AsSpan(), existing.AsSpan(combinedEnd));
|
||||
message.Request.Headers.Set("User-Agent", replacedCombined);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the bare agent-framework segment is present (stamped by
|
||||
// AgentFrameworkUserAgentPolicy when not hosted), upgrade it in place to the
|
||||
// combined hosted form so the wire never carries both segments simultaneously.
|
||||
// Mirrors Python where get_user_agent() returns a single combined string when the
|
||||
// hosted prefix is registered.
|
||||
var idx = existing.IndexOf(BareAgentFrameworkPrefix, StringComparison.Ordinal);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var end = existing.IndexOf(' ', idx);
|
||||
if (end < 0)
|
||||
{
|
||||
end = existing.Length;
|
||||
}
|
||||
|
||||
var replaced = string.Concat(existing.AsSpan(0, idx), s_supplementValue.AsSpan(), existing.AsSpan(end));
|
||||
message.Request.Headers.Set("User-Agent", replaced);
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}");
|
||||
}
|
||||
else
|
||||
|
||||
+8
-11
@@ -23,7 +23,7 @@ namespace Azure.AI.Projects;
|
||||
/// Provides extension methods for <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static partial class AzureAIProjectChatClientExtensions
|
||||
public static partial class AIProjectClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentReference"/>.
|
||||
@@ -63,7 +63,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
clientFactory,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,7 +132,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,7 +165,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,7 +246,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -268,10 +268,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = aiProjectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -298,7 +295,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -316,7 +313,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Framework-wide pipeline policy that appends the <c>agent-framework-dotnet/{version}</c>
|
||||
/// segment to outgoing <c>User-Agent</c> headers, mirroring the
|
||||
/// <c>agent-framework-python/{version}</c> contract used by every Python provider package.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The segment value is computed once from the <c>Microsoft.Agents.AI.Foundry</c> assembly's
|
||||
/// <see cref="AssemblyInformationalVersionAttribute"/>. The policy is idempotent on retries: if
|
||||
/// the segment is already present in the <c>User-Agent</c> header, the policy does not append
|
||||
/// it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The policy is registered by <c>FoundryChatClient</c> on the underlying chat client's
|
||||
/// <c>OpenAIRequestPolicies</c> hook so every outbound Foundry call carries the segment. The
|
||||
/// policy is currently colocated with the Foundry package; it is expected to migrate to a
|
||||
/// framework-wide location (such as <c>Microsoft.Agents.AI</c>) once another provider package
|
||||
/// adopts the same User-Agent contract.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
/// <summary>Gets the singleton policy instance.</summary>
|
||||
public static AgentFrameworkUserAgentPolicy Instance { get; } = new AgentFrameworkUserAgentPolicy();
|
||||
|
||||
private static readonly string s_segmentValue = CreateSegmentValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendHeader(PipelineMessage message)
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing!.Contains(s_segmentValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_segmentValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", s_segmentValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSegmentValue()
|
||||
{
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
if (typeof(AgentFrameworkUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// 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;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using
|
||||
/// Azure-specific agent capabilities.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal sealed class AzureAIProjectChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata? _metadata;
|
||||
private readonly AIProjectClient _agentClient;
|
||||
private readonly ProjectsAgentVersion? _agentVersion;
|
||||
private readonly ProjectsAgentRecord? _agentRecord;
|
||||
private readonly ChatOptions? _chatOptions;
|
||||
private readonly AgentReference _agentReference;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An instance of <see cref="AIProjectClient"/> to interact with Azure AI Agents services.</param>
|
||||
/// <param name="agentReference">An instance of <see cref="AgentReference"/> representing the specific agent to use.</param>
|
||||
/// <param name="defaultModelId">The default model to use for the agent, if applicable.</param>
|
||||
/// <param name="chatOptions">An instance of <see cref="ChatOptions"/> representing the options on how the agent was predefined.</param>
|
||||
/// <remarks>
|
||||
/// The <see cref="IChatClient"/> provided should be decorated with a <see cref="AzureAIProjectChatClient"/> for proper functionality.
|
||||
/// </remarks>
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgent(agentReference)
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._agentClient = aiProjectClient;
|
||||
this._agentReference = Throw.IfNull(agentReference);
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
this._chatOptions = chatOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An instance of <see cref="AIProjectClient"/> to interact with Azure AI Agents services.</param>
|
||||
/// <param name="agentRecord">An instance of <see cref="ProjectsAgentRecord"/> representing the specific agent to use.</param>
|
||||
/// <param name="chatOptions">An instance of <see cref="ChatOptions"/> representing the options on how the agent was predefined.</param>
|
||||
/// <remarks>
|
||||
/// The <see cref="IChatClient"/> provided should be decorated with a <see cref="AzureAIProjectChatClient"/> for proper functionality.
|
||||
/// </remarks>
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? chatOptions)
|
||||
: this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), chatOptions)
|
||||
{
|
||||
this._agentRecord = agentRecord;
|
||||
}
|
||||
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? chatOptions)
|
||||
: this(
|
||||
aiProjectClient,
|
||||
CreateAgentReference(Throw.IfNull(agentVersion)),
|
||||
(agentVersion.Definition as DeclarativeAgentDefinition)?.Model,
|
||||
chatOptions)
|
||||
{
|
||||
this._agentVersion = agentVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AgentReference"/> from an <see cref="ProjectsAgentVersion"/>.
|
||||
/// Uses the agent version's version if available, otherwise defaults to "latest".
|
||||
/// </summary>
|
||||
/// <param name="agentVersion">The agent version to create a reference from.</param>
|
||||
/// <returns>An <see cref="AgentReference"/> for the specified agent version.</returns>
|
||||
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.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
return new AgentReference(agentVersion.Name, version);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._agentClient
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentVersion))
|
||||
? this._agentVersion
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentRecord))
|
||||
? this._agentRecord
|
||||
: (serviceKey is null && serviceType == typeof(AgentReference))
|
||||
? this._agentReference
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var agentOptions = this.GetAgentEnabledChatOptions(options);
|
||||
|
||||
return await base.GetResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var agentOptions = this.GetAgentEnabledChatOptions(options);
|
||||
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options)
|
||||
{
|
||||
// Start with a clone of the base chat options defined for the agent, if any.
|
||||
ChatOptions agentEnabledChatOptions = this._chatOptions?.Clone() ?? new();
|
||||
|
||||
// Ignore per-request all options that can't be overridden.
|
||||
agentEnabledChatOptions.Instructions = null;
|
||||
agentEnabledChatOptions.Tools = null;
|
||||
agentEnabledChatOptions.Temperature = null;
|
||||
agentEnabledChatOptions.TopP = null;
|
||||
agentEnabledChatOptions.PresencePenalty = null;
|
||||
agentEnabledChatOptions.ResponseFormat = null;
|
||||
|
||||
// Use the conversation from the request, or the one defined at the client level.
|
||||
agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._chatOptions?.ConversationId;
|
||||
|
||||
// Preserve the original RawRepresentationFactory
|
||||
var originalFactory = options?.RawRepresentationFactory;
|
||||
|
||||
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
|
||||
{
|
||||
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
|
||||
{
|
||||
responseCreationOptions = new CreateResponseOptions();
|
||||
}
|
||||
|
||||
responseCreationOptions.Agent = this._agentReference;
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
responseCreationOptions.Patch.Remove("$.model"u8);
|
||||
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
|
||||
return responseCreationOptions;
|
||||
};
|
||||
|
||||
return agentEnabledChatOptions;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
|
||||
internal AzureAIProjectResponsesChatClient(AIProjectClient aiProjectClient, string defaultModelId)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(defaultModelId))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
}
|
||||
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._aiProjectClient
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
}
|
||||
#pragma warning restore OPENAI001
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry-specific extensions on <see cref="ChatClientAgent"/>. Mirrors Python's free
|
||||
/// <c>to_prompt_agent(agent)</c> function for agents whose underlying chat client is a
|
||||
/// <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class ChatClientAgentFoundryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the supplied agent into a <see cref="ProjectsAgentDefinition"/> ready to publish
|
||||
/// via <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only works on agents whose chat client is a <see cref="FoundryChatClient"/> and whose
|
||||
/// construction mode is convertible. The Agent Endpoint construction mode (Mode 3) is not
|
||||
/// convertible because no local definition exists; conversion in that case throws.
|
||||
/// </remarks>
|
||||
/// <param name="agent">The chat client agent to convert.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel an internal server-side fetch when the agent was constructed from a bare <see cref="AgentReference"/>.</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for publishing.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent's chat client is not a <see cref="FoundryChatClient"/>; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's <see cref="ChatOptions"/> for the Responses Agent mode (Mode 1); or the agent contains an <see cref="AITool"/> that cannot be converted to a <c>ResponseTool</c>.</exception>
|
||||
public static Task<ProjectsAgentDefinition> ToPromptAgentAsync(this ChatClientAgent agent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
return FoundryPromptAgentConverter.ConvertAsync(agent.ChatClient, agent.GetService<ChatOptions>(), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// The cached <see cref="AIProjectClient"/> supplied to or constructed by the active constructor.
|
||||
/// </summary>
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
|
||||
/// </summary>
|
||||
@@ -73,9 +68,8 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
: base(CreateInnerAgent(
|
||||
CreateProjectClient(projectEndpoint, credential, clientOptions),
|
||||
model, instructions, name, description, tools, clientFactory, loggerFactory, services,
|
||||
out var aiProjectClient))
|
||||
out _))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -87,9 +81,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. When supplied:
|
||||
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
|
||||
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
|
||||
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
|
||||
/// </list>
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
@@ -113,43 +109,37 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient))
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
|
||||
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
|
||||
/// Internal constructor used by the <c>AsAIAgent(this AIProjectClient, Uri, ...)</c>
|
||||
/// extension where the caller already has an <see cref="AIProjectClient"/> and the agent
|
||||
/// endpoint URI. Reuses the supplied client's pipeline (no new credential or transport is
|
||||
/// stamped) and surfaces the agent through a <see cref="FoundryChatClient"/> just like the
|
||||
/// public agent-endpoint ctor.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An existing <see cref="AIProjectClient"/> rooted at the same project as <paramref name="agentEndpoint"/>.</param>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
|
||||
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
|
||||
internal FoundryAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
|
||||
: base(CreateInnerAgentFromAgentEndpointReusingProjectClient(aiProjectClient, agentEndpoint, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have a
|
||||
/// configured <see cref="ChatClientAgent"/>. The inner agent already routes through a
|
||||
/// <see cref="FoundryChatClient"/> whose <c>GetService<AIProjectClient>()</c> surfaces
|
||||
/// the project client to downstream callers, so the agent does not also need a private
|
||||
/// <see cref="AIProjectClient"/> reference here.
|
||||
/// </summary>
|
||||
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
|
||||
internal FoundryAgent(ChatClientAgent innerAgent)
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
#region Convenience methods
|
||||
@@ -182,7 +172,13 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
|
||||
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
|
||||
// The inner FoundryChatClient surfaces an AIProjectClient via GetService for all
|
||||
// three construction modes (Plan #2 Agent Endpoint mode materialization). Resolve it through the
|
||||
// delegating chain at call time instead of caching a private reference on this agent.
|
||||
var aiProjectClient = this.GetService<AIProjectClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"FoundryAgent inner chain does not expose an AIProjectClient; cannot create a project-level conversation session.");
|
||||
var conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient();
|
||||
|
||||
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
@@ -196,17 +192,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
if (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
{
|
||||
return this._aiProjectClient;
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
#region Private helpers
|
||||
|
||||
private static AIAgent CreateInnerAgent(
|
||||
@@ -251,7 +236,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -288,16 +273,10 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
|
||||
/// constructing a project-scoped <see cref="ProjectOpenAIClient"/> and using
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// This routes the outbound URL through the per-agent endpoint shape that the Foundry service
|
||||
/// expects for hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
|
||||
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
|
||||
/// client with <c>Endpoint</c> and
|
||||
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
|
||||
/// <paramref name="agentEndpoint"/>; any policies the caller added via <c>AddPolicy</c>
|
||||
/// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor. The
|
||||
/// per-agent <see cref="ProjectOpenAIClient"/> shape and URL parsing are owned by
|
||||
/// <see cref="FoundryChatClient"/>; we just construct it in the Agent Endpoint mode (Mode 3)
|
||||
/// and pass the inner chat client through any caller-provided <paramref name="clientFactory"/>.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
@@ -305,44 +284,14 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services,
|
||||
out AIProjectClient outClient)
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
|
||||
IChatClient chatClient = new FoundryChatClient(agentEndpoint, credential, clientOptions);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
|
||||
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for an agent endpoint against a pre-built
|
||||
/// <see cref="AIProjectClient"/>. The caller is responsible for ensuring the supplied client
|
||||
/// is rooted at the same project as <paramref name="agentEndpoint"/>; the agent name is
|
||||
/// parsed from the endpoint URI and passed to
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// </summary>
|
||||
private static AIAgent BuildAgentEndpointInnerAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
IChatClient chatClient = aiProjectClient.ProjectOpenAIClient
|
||||
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
|
||||
.AsIChatClient();
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
@@ -358,6 +307,46 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="CreateInnerAgentFromAgentEndpoint"/> that reuses an existing
|
||||
/// <see cref="AIProjectClient"/>'s pipeline instead of stamping a fresh credential. Used by
|
||||
/// the <c>AsAIAgent(AIProjectClient, Uri agentEndpoint, ...)</c> extension overload.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpointReusingProjectClient(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentEndpoint, clientOptions: null);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
Id = agentName,
|
||||
Name = agentName,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
};
|
||||
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI. Delegates to <see cref="FoundryChatClient.ParseAgentEndpoint(Uri)"/>
|
||||
/// so the chat client and the agent share a single source of truth for the URL shape.
|
||||
/// </summary>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
=> FoundryChatClient.ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
@@ -369,90 +358,12 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
|
||||
{
|
||||
Throw.IfNull(endpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
clientOptions ??= new AIProjectClientOptions();
|
||||
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
return new AIProjectClient(endpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
internal static AIProjectClientOptions? CreateProjectClientOptions(ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
if (clientOptions is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copy pipeline behavior the caller configured on the per-agent options bag onto the
|
||||
// project-level options bag so the agent endpoint client honors it. UserAgentApplicationId
|
||||
// is project-level (not derived from the agent endpoint), so it must be carried through too.
|
||||
var projectOptions = new AIProjectClientOptions
|
||||
{
|
||||
Transport = clientOptions.Transport,
|
||||
RetryPolicy = clientOptions.RetryPolicy,
|
||||
NetworkTimeout = clientOptions.NetworkTimeout,
|
||||
MessageLoggingPolicy = clientOptions.MessageLoggingPolicy,
|
||||
UserAgentApplicationId = clientOptions.UserAgentApplicationId,
|
||||
};
|
||||
|
||||
if (clientOptions.ClientLoggingOptions is not null)
|
||||
{
|
||||
projectOptions.ClientLoggingOptions = clientOptions.ClientLoggingOptions;
|
||||
}
|
||||
|
||||
return projectOptions;
|
||||
return new AIProjectClient(endpoint, credential, clientOptions ?? new AIProjectClientOptions());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.VectorStores;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry-specific extensions on <see cref="FoundryAgent"/>. Hosts the prompt-agent converter
|
||||
/// plus thin forwarders that surface the file and vector-store helpers from the inner
|
||||
/// <see cref="FoundryChatClient"/> at the agent level so callers do not need to drop down to
|
||||
/// <c>agent.GetService<FoundryChatClient>().X()</c> for common workflows.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the supplied <see cref="FoundryAgent"/> into a <see cref="ProjectsAgentDefinition"/>
|
||||
/// ready to publish via <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Agent Endpoint construction mode (Mode 3) is not convertible because no local
|
||||
/// definition exists; conversion in that case throws <see cref="InvalidOperationException"/>.
|
||||
/// </remarks>
|
||||
/// <param name="agent">The Foundry agent to convert.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel an internal server-side fetch when the agent was constructed from a bare <see cref="AgentReference"/>.</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for publishing.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent's chat client is not a <see cref="FoundryChatClient"/>; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's <see cref="ChatOptions"/> for the Responses Agent mode (Mode 1); or the agent contains an <see cref="AITool"/> that cannot be converted to a <c>ResponseTool</c>.</exception>
|
||||
public static Task<ProjectsAgentDefinition> ToPromptAgentAsync(this FoundryAgent agent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
|
||||
var innerChatClient = agent.GetService<IChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync could not resolve the inner IChatClient on the FoundryAgent.");
|
||||
var chatOptions = agent.GetService<ChatOptions>();
|
||||
return FoundryPromptAgentConverter.ConvertAsync(innerChatClient, chatOptions, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file to the project. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.UploadFileAsync(string, FileUploadPurpose, CancellationToken)"/>
|
||||
/// on the agent's inner <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the upload pipeline.</param>
|
||||
/// <param name="filePath">Path to the file to upload.</param>
|
||||
/// <param name="purpose">The upload purpose (e.g. <see cref="FileUploadPurpose.Assistants"/>).</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the upload.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/> via <see cref="AIAgent.GetService{TService}(object?)"/>.</exception>
|
||||
public static Task<OpenAIFile> UploadFileAsync(this FoundryAgent agent, string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).UploadFileAsync(filePath, purpose, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a previously uploaded file. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.DeleteFileAsync(string, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the file pipeline.</param>
|
||||
/// <param name="fileId">The file id returned by <see cref="UploadFileAsync(FoundryAgent, string, FileUploadPurpose, CancellationToken)"/>.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
public static Task<FileDeletionResult> DeleteFileAsync(this FoundryAgent agent, string fileId, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).DeleteFileAsync(fileId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the supplied files, creates a vector store containing them, and waits until the
|
||||
/// store leaves the in-progress state. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.CreateVectorStoreAsync(string, IEnumerable{string}, TimeSpan?, TimeSpan?, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the file and vector-store pipeline.</param>
|
||||
/// <param name="name">The vector store name.</param>
|
||||
/// <param name="filePaths">Paths to files to upload and attach to the store.</param>
|
||||
/// <param name="expiresAfter">Optional last-active-at expiration window.</param>
|
||||
/// <param name="pollingTimeout">Optional upper bound on the wait for the vector store to leave the in-progress state. Defaults to 5 minutes; pass <see cref="Timeout.InfiniteTimeSpan"/> to disable.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the orchestration.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
/// <exception cref="TimeoutException">The vector store did not leave the in-progress state within <paramref name="pollingTimeout"/>.</exception>
|
||||
public static Task<VectorStore> CreateVectorStoreAsync(this FoundryAgent agent, string name, IEnumerable<string> filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).CreateVectorStoreAsync(name, filePaths, expiresAfter, pollingTimeout, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a vector store. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.DeleteVectorStoreAsync(string, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the vector-store pipeline.</param>
|
||||
/// <param name="vectorStoreId">The vector store id.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
public static Task<VectorStoreDeletionResult> DeleteVectorStoreAsync(this FoundryAgent agent, string vectorStoreId, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).DeleteVectorStoreAsync(vectorStoreId, cancellationToken);
|
||||
|
||||
private static FoundryChatClient RequireFoundryChatClient(FoundryAgent agent)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
return agent.GetService<FoundryChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"FoundryAgent does not expose a FoundryChatClient via GetService<FoundryChatClient>(). " +
|
||||
"File and vector-store helpers require the agent's inner chat client to be a FoundryChatClient.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
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;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.Responses;
|
||||
using OpenAI.VectorStores;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry chat-client decorator that unifies the three Foundry chat-client construction
|
||||
/// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes
|
||||
/// Foundry-specific concerns: <c>microsoft.foundry</c> telemetry tagging,
|
||||
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, and (for Prompt Agents)
|
||||
/// per-request payload mutation that injects the agent reference and strips per-request
|
||||
/// overrides that the server owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Replaces the previous <c>AzureAIProjectChatClient</c> and <c>AzureAIProjectResponsesChatClient</c>
|
||||
/// decorators. All Foundry entry points (the public <c>FoundryAgent</c> constructors and the
|
||||
/// <c>AIProjectClientExtensions.AsAIAgent</c> overloads) now construct a
|
||||
/// <see cref="FoundryChatClient"/> internally, so telemetry and the agent-framework User-Agent
|
||||
/// segment are uniform across paths.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The three construction modes are:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Responses Agent</b> (Mode 1): direct Responses API call against a project-level model id; no server-side agent definition exists. Constructed from <c>(AIProjectClient, modelId)</c>.</description></item>
|
||||
/// <item><description><b>Prompt Agent</b> (Mode 2): server-side agent definition (a <see cref="ProjectsAgentDefinition"/>, typically a <see cref="DeclarativeAgentDefinition"/>) invoked by <see cref="AgentReference"/> against the project Responses URL. Constructed from <see cref="AgentReference"/>, <see cref="ProjectsAgentVersion"/>, or <see cref="ProjectsAgentRecord"/>.</description></item>
|
||||
/// <item><description><b>Agent Endpoint</b> (Mode 3): invocation via the per-agent endpoint URL <c>…/projects/{p}/agents/{name}/endpoint/protocols/openai</c>. The agent behind the endpoint can be either a hosted (container-backed) agent or a Prompt Agent. Constructed from <c>(Uri agentEndpoint, credential)</c>.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Note: "Hosted Agent" refers to a container-based runtime agent (see
|
||||
/// <c>Microsoft.Agents.AI.Foundry.Hosting</c>) and is the <i>kind</i> of agent that may sit
|
||||
/// behind an Agent Endpoint. It is not synonymous with the Agent Endpoint mode itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
private readonly AIProjectClient? _aiProjectClient;
|
||||
private readonly AgentReference? _agentReference;
|
||||
private readonly ProjectsAgentVersion? _agentVersion;
|
||||
private readonly ProjectsAgentRecord? _agentRecord;
|
||||
private readonly ChatOptions? _baseChatOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Responses Agent mode (Mode 1): direct Responses API
|
||||
/// call against a project-level model id; no server-side agent definition exists.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The project client.</param>
|
||||
/// <param name="modelId">The model deployment id.</param>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, string modelId)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(modelId))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId);
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2): server-side agent
|
||||
/// definition invoked by <see cref="AgentReference"/>.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? baseChatOptions)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgent(Throw.IfNull(agentReference))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._agentReference = agentReference;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
this._baseChatOptions = baseChatOptions;
|
||||
this.AgentName = agentReference.Name;
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2, record variant):
|
||||
/// server-side agent definition invoked by record, resolving to the latest version.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? baseChatOptions)
|
||||
: this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), baseChatOptions)
|
||||
{
|
||||
this._agentRecord = agentRecord;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2, version variant):
|
||||
/// server-side agent definition invoked by a specific version.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? baseChatOptions)
|
||||
: this(
|
||||
aiProjectClient,
|
||||
CreateAgentReference(Throw.IfNull(agentVersion)),
|
||||
(agentVersion.Definition as DeclarativeAgentDefinition)?.Model,
|
||||
baseChatOptions)
|
||||
{
|
||||
this._agentVersion = agentVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Agent Endpoint mode (Mode 3): invocation via the
|
||||
/// per-agent endpoint URL. Parses the URL into its per-agent
|
||||
/// <see cref="ProjectOpenAIClient"/> shape internally and forwards through the resulting
|
||||
/// responses client.
|
||||
/// </summary>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">Optional per-agent client options. <c>Endpoint</c> and <c>AgentName</c> are owned by this ctor and overridden with values derived from <paramref name="agentEndpoint"/>.</param>
|
||||
internal FoundryChatClient(Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions)
|
||||
: this(BuildAgentEndpointInner(agentEndpoint, credential, clientOptions))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Agent Endpoint mode (Mode 3) by reusing an existing
|
||||
/// <see cref="AIProjectClient"/>'s pipeline. Equivalent to the
|
||||
/// <see cref="FoundryChatClient(Uri, AuthenticationTokenProvider, ProjectOpenAIClientOptions?)"/>
|
||||
/// constructor but skips building a fresh per-agent pipeline: the project-level
|
||||
/// <see cref="ProjectOpenAIClient"/> on <paramref name="aiProjectClient"/> is used directly.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The project client already configured at the project root containing <paramref name="agentEndpoint"/>.</param>
|
||||
/// <param name="agentEndpoint">The per-agent endpoint URI. Same shape constraints as the other agent-endpoint ctor.</param>
|
||||
/// <param name="clientOptions">Optional per-agent client options applied to the per-agent <c>GetProjectResponsesClientForAgentEndpoint</c> call.</param>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, Uri agentEndpoint, ProjectOpenAIClientOptions? clientOptions)
|
||||
: this(BuildAgentEndpointInnerFromProjectClient(aiProjectClient, agentEndpoint, clientOptions))
|
||||
{
|
||||
}
|
||||
|
||||
private FoundryChatClient(AgentEndpointInner inner)
|
||||
: base(inner.ChatClient)
|
||||
{
|
||||
this._aiProjectClient = inner.AIProjectClient;
|
||||
this.AgentName = inner.AgentName;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry");
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent name associated with this chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Set in two cases:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Prompt Agent mode (Mode 2): the value of <see cref="AgentReference.Name"/> supplied at
|
||||
/// construction.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Agent Endpoint mode (Mode 3): the agent name segment parsed from the supplied agent
|
||||
/// endpoint URI.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Returns <see langword="null"/> for the Responses Agent mode (Mode 1) where no agent name
|
||||
/// exists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? AgentName { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._aiProjectClient
|
||||
: (serviceKey is null && serviceType == typeof(AgentReference))
|
||||
? this._agentReference
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentVersion))
|
||||
? this._agentVersion
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentRecord))
|
||||
? this._agentRecord
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = this._agentReference is not null
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
return await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = this._agentReference is not null
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
#region File and vector-store helpers (mirrors Python's foundry_chat_client surface)
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a single file to the project for the supplied purpose. The upload is performed
|
||||
/// against the project-level <see cref="AIProjectClient"/> reachable via
|
||||
/// <see cref="GetService(Type, object?)"/>, so this method works uniformly across all three
|
||||
/// FoundryChatClient construction modes.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Absolute or relative path to the file to upload. The file must exist.</param>
|
||||
/// <param name="purpose">The file upload purpose (e.g. <see cref="FileUploadPurpose.Assistants"/>).</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the upload.</param>
|
||||
/// <returns>The created <see cref="OpenAIFile"/> as returned by the service.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="filePath"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="FileNotFoundException">The file at <paramref name="filePath"/> does not exist.</exception>
|
||||
public async Task<OpenAIFile> UploadFileAsync(string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(filePath);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"File not found: '{filePath}'.", filePath);
|
||||
}
|
||||
|
||||
var fileClient = this.GetOpenAIFileClient();
|
||||
// Use the Stream overload to honor cancellation; the (string, purpose) overload has no
|
||||
// CancellationToken parameter in the OpenAI SDK.
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var result = await fileClient.UploadFileAsync(stream, Path.GetFileName(filePath), purpose, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a file previously uploaded to the project.</summary>
|
||||
/// <param name="fileId">The file id returned by <see cref="UploadFileAsync(string, FileUploadPurpose, CancellationToken)"/>.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <returns>The deletion result.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="fileId"/> is <see langword="null"/> or whitespace.</exception>
|
||||
public async Task<FileDeletionResult> DeleteFileAsync(string fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(fileId);
|
||||
var fileClient = this.GetOpenAIFileClient();
|
||||
var result = await fileClient.DeleteFileAsync(fileId, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the supplied files, creates a vector store containing them, waits until the
|
||||
/// store finishes ingesting its files (status leaves <see cref="VectorStoreStatus.InProgress"/>),
|
||||
/// and returns the <see cref="VectorStore"/>. Mirrors Python's
|
||||
/// <c>foundry_chat_client.create_vector_store(name, files, expires_after_days)</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">The vector store name.</param>
|
||||
/// <param name="filePaths">Paths to files to upload and attach to the store.</param>
|
||||
/// <param name="expiresAfter">Optional last-active-at expiration window. When supplied, the vector store expires this many days after its last use.</param>
|
||||
/// <param name="pollingTimeout">Optional upper bound on the wait for the vector store to leave <see cref="VectorStoreStatus.InProgress"/>. Defaults to 5 minutes when not supplied; pass <see cref="Timeout.InfiniteTimeSpan"/> to disable. Independent of <paramref name="cancellationToken"/>: cancellation always wins.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the orchestration.</param>
|
||||
/// <returns>The created and fully-ready <see cref="VectorStore"/>. The returned instance reflects the state observed after polling completes; it may be in <see cref="VectorStoreStatus.Completed"/> (typical), <see cref="VectorStoreStatus.Expired"/>, or any other terminal status returned by the service. Only <see cref="VectorStoreStatus.InProgress"/> is polled.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// File-upload semantics are best-effort: when one of the per-file uploads throws, this method
|
||||
/// makes a best-effort attempt to delete the files it has already uploaded so they do not
|
||||
/// accumulate as orphaned resources on the project, then rethrows the original exception. The
|
||||
/// cleanup itself does not throw — its failures are silently ignored because the caller is
|
||||
/// already receiving a more meaningful exception from the original upload failure.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cancellation aborts the polling loop with an <see cref="OperationCanceledException"/>; any
|
||||
/// already-uploaded files and the partially-created vector store remain on the project and are
|
||||
/// the caller's responsibility to clean up. The same applies when the polling timeout elapses
|
||||
/// (a <see cref="TimeoutException"/> is thrown instead).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException"><paramref name="name"/> is <see langword="null"/> or whitespace, or <paramref name="filePaths"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="TimeoutException">The vector store did not leave <see cref="VectorStoreStatus.InProgress"/> within <paramref name="pollingTimeout"/>.</exception>
|
||||
public async Task<VectorStore> CreateVectorStoreAsync(string name, IEnumerable<string> filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
Throw.IfNull(filePaths);
|
||||
|
||||
var fileIds = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var path in filePaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var uploaded = await this.UploadFileAsync(path, FileUploadPurpose.Assistants, cancellationToken).ConfigureAwait(false);
|
||||
fileIds.Add(uploaded.Id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Q-B: best-effort cleanup of files already uploaded before the mid-loop failure so
|
||||
// they do not accumulate as orphaned resources on the project. Swallow cleanup
|
||||
// exceptions — the caller is already going to see the original upload exception, and
|
||||
// there is nothing useful we can do with a secondary delete failure.
|
||||
await this.BestEffortDeleteFilesAsync(fileIds).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
var options = new VectorStoreCreationOptions
|
||||
{
|
||||
Name = name,
|
||||
};
|
||||
foreach (var id in fileIds)
|
||||
{
|
||||
options.FileIds.Add(id);
|
||||
}
|
||||
if (expiresAfter is { } window)
|
||||
{
|
||||
options.ExpirationPolicy = new VectorStoreExpirationPolicy(VectorStoreExpirationAnchor.LastActiveAt, (int)Math.Ceiling(window.TotalDays));
|
||||
}
|
||||
|
||||
var vectorStoreClient = this.GetVectorStoreClient();
|
||||
var createResult = await vectorStoreClient.CreateVectorStoreAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var created = createResult.Value;
|
||||
|
||||
// Q-A: poll until the vector store leaves the in-progress state. Without this the helper
|
||||
// hands the caller a vector store whose file ingestion may still be running, defeating
|
||||
// the purpose of the one-call wrapper.
|
||||
return await WaitForVectorStoreReadyAsync(vectorStoreClient, created, pollingTimeout ?? s_defaultPollingTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task BestEffortDeleteFilesAsync(IEnumerable<string> fileIds)
|
||||
{
|
||||
foreach (var id in fileIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pass CancellationToken.None: cleanup runs in the catch path; the caller's
|
||||
// token may already be cancelled and we still want to do our best to free
|
||||
// orphaned resources before propagating the original exception.
|
||||
await this.DeleteFileAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore cleanup failures; see XML doc on CreateVectorStoreAsync.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on <see cref="WaitForVectorStoreReadyAsync"/> when the caller does not supply one. Chosen to comfortably cover normal Foundry vector-store ingestion (seconds to a minute for modest file sets) while still surfacing a clear failure if the server is stuck.</summary>
|
||||
private static readonly TimeSpan s_defaultPollingTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
private static async Task<VectorStore> WaitForVectorStoreReadyAsync(VectorStoreClient client, VectorStore initial, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (initial.Status != VectorStoreStatus.InProgress)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var delay = TimeSpan.FromMilliseconds(250);
|
||||
var maxDelay = TimeSpan.FromSeconds(2);
|
||||
var current = initial;
|
||||
while (current.Status == VectorStoreStatus.InProgress)
|
||||
{
|
||||
if (timeout != Timeout.InfiniteTimeSpan && stopwatch.Elapsed >= timeout)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Vector store '{current.Id}' did not leave the in-progress state within {timeout.TotalSeconds:0.##} seconds.");
|
||||
}
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
var refreshed = await client.GetVectorStoreAsync(current.Id, cancellationToken).ConfigureAwait(false);
|
||||
current = refreshed.Value;
|
||||
|
||||
if (delay < maxDelay)
|
||||
{
|
||||
var next = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
|
||||
delay = next < maxDelay ? next : maxDelay;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a vector store. The associated files (if any) are not deleted by this method; call <see cref="DeleteFileAsync(string, CancellationToken)"/> separately to clean them up.</summary>
|
||||
/// <param name="vectorStoreId">The vector store id.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <returns>The deletion result.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="vectorStoreId"/> is <see langword="null"/> or whitespace.</exception>
|
||||
public async Task<VectorStoreDeletionResult> DeleteVectorStoreAsync(string vectorStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(vectorStoreId);
|
||||
var vectorStoreClient = this.GetVectorStoreClient();
|
||||
var result = await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
private OpenAIFileClient GetOpenAIFileClient()
|
||||
{
|
||||
var projectClient = this._aiProjectClient
|
||||
?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient.");
|
||||
return projectClient.GetProjectOpenAIClient().GetOpenAIFileClient();
|
||||
}
|
||||
|
||||
private VectorStoreClient GetVectorStoreClient()
|
||||
{
|
||||
var projectClient = this._aiProjectClient
|
||||
?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient.");
|
||||
return projectClient.GetProjectOpenAIClient().GetVectorStoreClient();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
/// and returns the agent name and the derived project-root URI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tolerates trailing slash, casing variants on <c>/agents/</c> and the suffix segment, and
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
|
||||
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options)
|
||||
{
|
||||
// Start with a clone of the base chat options defined for the agent, if any.
|
||||
ChatOptions agentEnabledChatOptions = this._baseChatOptions?.Clone() ?? new();
|
||||
|
||||
// Ignore per-request all options that can't be overridden.
|
||||
agentEnabledChatOptions.Instructions = null;
|
||||
agentEnabledChatOptions.Tools = null;
|
||||
agentEnabledChatOptions.Temperature = null;
|
||||
agentEnabledChatOptions.TopP = null;
|
||||
agentEnabledChatOptions.PresencePenalty = null;
|
||||
agentEnabledChatOptions.ResponseFormat = null;
|
||||
|
||||
// Use the conversation from the request, or the one defined at the client level.
|
||||
agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._baseChatOptions?.ConversationId;
|
||||
|
||||
// Preserve the original RawRepresentationFactory.
|
||||
var originalFactory = options?.RawRepresentationFactory;
|
||||
|
||||
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
|
||||
{
|
||||
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
|
||||
{
|
||||
responseCreationOptions = new CreateResponseOptions();
|
||||
}
|
||||
|
||||
responseCreationOptions.Agent = this._agentReference;
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
|
||||
responseCreationOptions.Patch.Remove("$.model"u8);
|
||||
#pragma warning restore SCME0001
|
||||
|
||||
return responseCreationOptions;
|
||||
};
|
||||
|
||||
return agentEnabledChatOptions;
|
||||
}
|
||||
|
||||
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.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
return new AgentReference(agentVersion.Name, version);
|
||||
}
|
||||
|
||||
private static AgentEndpointInner BuildAgentEndpointInner(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
var (agentName, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
|
||||
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
|
||||
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
|
||||
|
||||
var chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
|
||||
|
||||
// Materialize a project-level AIProjectClient from the parsed project root so
|
||||
// GetService<AIProjectClient>() returns non-null for all FoundryChatClient
|
||||
// construction modes. Project-level helpers (file upload, vector store create/delete)
|
||||
// depend on this. RBAC for those calls is at the project level; if the supplied
|
||||
// credential lacks project-scope permissions, the SDK surfaces a clean 401/403 at
|
||||
// call time. The four observable primitive ClientPipelineOptions properties are
|
||||
// propagated from the caller's per-agent options bag so test-injected transports and
|
||||
// explicit RetryPolicy / NetworkTimeout / UserAgentApplicationId reach the
|
||||
// project-level pipeline. Pipeline policies added via AddPolicy on the caller bag are
|
||||
// NOT propagated because ClientPipelineOptions does not publicly enumerate policies.
|
||||
var aiProjectClientOptions = new AIProjectClientOptions();
|
||||
if (clientOptions is not null)
|
||||
{
|
||||
if (clientOptions.RetryPolicy is not null)
|
||||
{
|
||||
aiProjectClientOptions.RetryPolicy = clientOptions.RetryPolicy;
|
||||
}
|
||||
if (clientOptions.NetworkTimeout is not null)
|
||||
{
|
||||
aiProjectClientOptions.NetworkTimeout = clientOptions.NetworkTimeout;
|
||||
}
|
||||
if (clientOptions.Transport is not null)
|
||||
{
|
||||
aiProjectClientOptions.Transport = clientOptions.Transport;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
|
||||
{
|
||||
aiProjectClientOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
|
||||
}
|
||||
}
|
||||
var aiProjectClient = new AIProjectClient(projectRoot, credential, aiProjectClientOptions);
|
||||
|
||||
return new AgentEndpointInner(chatClient, aiProjectClient, agentName);
|
||||
}
|
||||
|
||||
private static AgentEndpointInner BuildAgentEndpointInnerFromProjectClient(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
|
||||
var chatClient = aiProjectClient.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
|
||||
.AsIChatClient();
|
||||
|
||||
// Reuse the caller's AIProjectClient verbatim — no new pipeline is materialized.
|
||||
return new AgentEndpointInner(chatClient, aiProjectClient, agentName);
|
||||
}
|
||||
|
||||
/// <summary>Best-effort registration of <see cref="AgentFrameworkUserAgentPolicy"/> via the MEAI <see cref="OpenAIRequestPolicies"/> hook with at-most-once dedup per pipeline.</summary>
|
||||
private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient)
|
||||
{
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
// OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against
|
||||
// the private _entries collection on the OpenAIRequestPolicies instance, so the
|
||||
// policy is registered at most once even when many FoundryChatClient instances share
|
||||
// the same underlying chat client.
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
AgentFrameworkUserAgentPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
|
||||
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
|
||||
|
||||
private readonly struct AgentEndpointInner
|
||||
{
|
||||
public AgentEndpointInner(IChatClient chatClient, AIProjectClient aiProjectClient, string agentName)
|
||||
{
|
||||
this.ChatClient = chatClient;
|
||||
this.AIProjectClient = aiProjectClient;
|
||||
this.AgentName = agentName;
|
||||
}
|
||||
|
||||
public IChatClient ChatClient { get; }
|
||||
public AIProjectClient AIProjectClient { get; }
|
||||
public string AgentName { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
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 Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Shared internal implementation behind the public <c>ToPromptAgentAsync</c> extension methods
|
||||
/// on <see cref="ChatClientAgent"/> and <see cref="FoundryAgent"/>. Converts a Foundry-backed
|
||||
/// agent into a <see cref="ProjectsAgentDefinition"/> ready to publish via
|
||||
/// <see cref="AgentAdministrationClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dispatch by <see cref="FoundryChatClient"/> construction mode (reachable via
|
||||
/// <see cref="IChatClient.GetService(Type, object?)"/>):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Responses Agent (Mode 1)</b>: synthesize a <see cref="DeclarativeAgentDefinition"/> from the agent's <see cref="ChatOptions"/>.</description></item>
|
||||
/// <item><description><b>Prompt Agent (Mode 2, cached version)</b>: return the cached <see cref="ProjectsAgentVersion.Definition"/>.</description></item>
|
||||
/// <item><description><b>Prompt Agent (Mode 2, AgentReference-only)</b>: fetch the latest version from the service and return its definition.</description></item>
|
||||
/// <item><description><b>Agent Endpoint (Mode 3)</b>: throw — no local definition exists to convert.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal static class FoundryPromptAgentConverter
|
||||
{
|
||||
/// <summary>Performs the conversion for an agent whose chat client and chat options are supplied.</summary>
|
||||
/// <param name="chatClient">The chat client extracted from the calling agent (must surface a <see cref="FoundryChatClient"/> via <see cref="IChatClient.GetService(Type, object?)"/>).</param>
|
||||
/// <param name="chatOptions">The agent's chat options (model id, instructions, temperature, top-p, tools). Required for the Responses Agent mode; ignored for the Prompt Agent mode.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel a server-side fetch (Prompt Agent AgentReference path).</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the chat client is not Foundry-backed, the agent was constructed via the Agent Endpoint mode, no model id is set for the Responses Agent mode, or an unsupported <see cref="AITool"/> is encountered.</exception>
|
||||
public static async Task<ProjectsAgentDefinition> ConvertAsync(IChatClient chatClient, ChatOptions? chatOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(chatClient);
|
||||
|
||||
var foundryChatClient = chatClient.GetService<FoundryChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync requires a FoundryChatClient-backed agent. " +
|
||||
"The supplied agent's chat client does not expose a FoundryChatClient via GetService<FoundryChatClient>().");
|
||||
|
||||
// Prompt Agent (Mode 2) with a cached server-side version (constructed via ProjectsAgentVersion or ProjectsAgentRecord).
|
||||
if (foundryChatClient.GetService<ProjectsAgentVersion>() is { } cachedVersion)
|
||||
{
|
||||
return cachedVersion.Definition;
|
||||
}
|
||||
|
||||
// Prompt Agent (Mode 2) AgentReference-only: fetch the agent definition from the service.
|
||||
// Honor a pinned AgentReference.Version when present (Q-C fix); fall back to the latest
|
||||
// version only when the reference is unpinned ("", null, or "latest").
|
||||
if (foundryChatClient.GetService<AgentReference>() is { } agentReference)
|
||||
{
|
||||
var aiProjectClient = foundryChatClient.GetService<AIProjectClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot fetch the agent version because the FoundryChatClient does not expose an AIProjectClient.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agentReference.Version)
|
||||
&& !string.Equals(agentReference.Version, "latest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var pinnedVersion = await aiProjectClient.AgentAdministrationClient
|
||||
.GetAgentVersionAsync(agentReference.Name, agentReference.Version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return pinnedVersion.Value.Definition;
|
||||
}
|
||||
|
||||
var record = await aiProjectClient.AgentAdministrationClient
|
||||
.GetAgentAsync(agentReference.Name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return record.Value.GetLatestVersion().Definition;
|
||||
}
|
||||
|
||||
// Agent Endpoint (Mode 3): AgentName is set (parsed from URL) but no AgentReference exists
|
||||
// locally. The agent definition lives only on the server and is not retrievable through this
|
||||
// chat client, so conversion is not supported here.
|
||||
if (foundryChatClient.AgentName is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync is not supported for agents constructed via the Agent Endpoint mode (Mode 3); " +
|
||||
"no local definition exists to convert.");
|
||||
}
|
||||
|
||||
// Responses Agent (Mode 1): synthesize from ChatOptions.
|
||||
return SynthesizeFromChatOptions(chatOptions);
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition SynthesizeFromChatOptions(ChatOptions? chatOptions)
|
||||
{
|
||||
if (chatOptions is null || string.IsNullOrWhiteSpace(chatOptions.ModelId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync requires a model id on the agent's ChatOptions to synthesize a prompt agent definition.");
|
||||
}
|
||||
|
||||
var definition = new DeclarativeAgentDefinition(chatOptions.ModelId!)
|
||||
{
|
||||
Instructions = chatOptions.Instructions,
|
||||
Temperature = chatOptions.Temperature,
|
||||
TopP = chatOptions.TopP,
|
||||
};
|
||||
|
||||
if (chatOptions.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
definition.Tools.Add(ConvertTool(tool));
|
||||
}
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static ResponseTool ConvertTool(AITool tool)
|
||||
{
|
||||
Throw.IfNull(tool);
|
||||
|
||||
if (tool is AIFunction function)
|
||||
{
|
||||
// strictModeEnabled is intentionally true to match the Python spec's
|
||||
// default behavior. JsonSchema on AIFunction is a JsonElement; serialize via its
|
||||
// string form so the payload matches what callers pass elsewhere in this codebase.
|
||||
return ResponseTool.CreateFunctionTool(
|
||||
function.Name,
|
||||
BinaryData.FromString(function.JsonSchema.ToString() ?? "{}"),
|
||||
strictModeEnabled: true,
|
||||
function.Description);
|
||||
}
|
||||
|
||||
if (tool.GetService(typeof(ResponseTool)) is ResponseTool responseTool)
|
||||
{
|
||||
return responseTool;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot convert AITool of type '{tool.GetType().Name}' to a ResponseTool. " +
|
||||
"Only AIFunction and AITool instances that wrap a ResponseTool (such as those produced by FoundryAITool factories) are supported.");
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
internal static class RequestOptionsExtensions
|
||||
{
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy();
|
||||
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AddUserAgentHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AddUserAgentHeader(message);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private static void AddUserAgentHeader(PipelineMessage message) =>
|
||||
message.Request.Headers.Add("User-Agent", s_userAgentValue);
|
||||
|
||||
private static string CreateUserAgentValue()
|
||||
{
|
||||
const string Name = "MEAI";
|
||||
|
||||
if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
@@ -249,6 +250,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
providers.Add(skillsProvider);
|
||||
}
|
||||
|
||||
if (options?.BackgroundAgents is IEnumerable<AIAgent> backgroundAgents)
|
||||
{
|
||||
var materializedAgents = backgroundAgents.ToList();
|
||||
if (materializedAgents.Count > 0)
|
||||
{
|
||||
providers.Add(new BackgroundAgentsProvider(materializedAgents, options.BackgroundAgentsProviderOptions));
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
|
||||
{
|
||||
providers.AddRange(userProviders);
|
||||
|
||||
@@ -218,4 +218,26 @@ public sealed class HarnessAgentOptions
|
||||
/// This property is ignored when <see cref="DisableOpenTelemetry"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public string? OpenTelemetrySourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of background agents available for delegation via <see cref="BackgroundAgentsProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-null and non-empty, a <see cref="BackgroundAgentsProvider"/> is automatically included in the
|
||||
/// agent's context providers, enabling the agent to start, monitor, and retrieve results from background tasks.
|
||||
/// When <see langword="null"/> or empty, no <see cref="BackgroundAgentsProvider"/> is configured.
|
||||
/// Each agent in the collection must have a non-empty <see cref="AIAgent.Name"/> and names must be unique
|
||||
/// (case-insensitive). If these requirements are not met, <see cref="BackgroundAgentsProvider"/> will throw
|
||||
/// an <see cref="System.ArgumentException"/> during construction.
|
||||
/// </remarks>
|
||||
public IEnumerable<AIAgent>? BackgroundAgents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="BackgroundAgentsProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this to customize instructions or agent list formatting for the background agents feature.
|
||||
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
|
||||
/// </remarks>
|
||||
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
|
||||
.YieldsOutput<AgentResponseUpdate>()
|
||||
.YieldsOutput<AgentResponse>()
|
||||
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.Responses;
|
||||
using OpenAI.VectorStores;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the file and vector-store forwarder extensions on
|
||||
/// <see cref="FoundryAgent"/> declared in <see cref="FoundryAgentExtensions"/>. End-to-end
|
||||
/// counterparts of the unit tests in
|
||||
/// <c>FoundryAgentExtensionsTests</c> that exercise the live Foundry project pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="FoundryVersionedAgentCreateTests.CreateAgent_CreatesAgentWithVectorStoresAsync(string)"/>
|
||||
/// in shape (file upload → vector store creation → FileSearchTool answer → cleanup), but routes
|
||||
/// every helper call through the new <see cref="FoundryAgent"/> extensions instead of the raw
|
||||
/// <c>projectOpenAIClient.GetProjectFilesClient()</c> / <c>GetProjectVectorStoresClient()</c>
|
||||
/// path. Skipped by default for the same reasons as the existing vector-store IT (cost and
|
||||
/// runtime); flip Skip to run manually after seeding the right Foundry project.
|
||||
/// </remarks>
|
||||
public class FoundryAgentExtensionsTests
|
||||
{
|
||||
private readonly AIProjectClient _client = new(
|
||||
new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
|
||||
TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task UploadFileAsync_ViaAgentExtension_UploadsToProjectAsync()
|
||||
{
|
||||
// Arrange — non-versioned Responses Agent (Mode 1) so we do not have to provision a server-side agent.
|
||||
var agent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var foundryAgent = this.WrapAsFoundryAgent(agent);
|
||||
|
||||
var filePath = Path.GetTempFileName() + ".txt";
|
||||
File.WriteAllText(filePath, "agent-extensions integration test payload");
|
||||
|
||||
OpenAIFile? uploaded = null;
|
||||
try
|
||||
{
|
||||
// Act.
|
||||
uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.False(string.IsNullOrEmpty(uploaded.Id));
|
||||
Assert.Equal(Path.GetFileName(filePath), uploaded.Filename);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (uploaded is not null)
|
||||
{
|
||||
await foundryAgent.DeleteFileAsync(uploaded.Id);
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task DeleteFileAsync_ViaAgentExtension_RemovesUploadedFileAsync()
|
||||
{
|
||||
var agent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var foundryAgent = this.WrapAsFoundryAgent(agent);
|
||||
|
||||
var filePath = Path.GetTempFileName() + ".txt";
|
||||
File.WriteAllText(filePath, "delete-me payload");
|
||||
|
||||
try
|
||||
{
|
||||
var uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants);
|
||||
|
||||
// Act.
|
||||
var result = await foundryAgent.DeleteFileAsync(uploaded.Id);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(uploaded.Id, result.FileId);
|
||||
Assert.True(result.Deleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task CreateVectorStoreAsync_ViaAgentExtension_BuildsStoreAndAnswersFileSearchQuestionAsync()
|
||||
{
|
||||
// Mirrors CreateAgent_CreatesAgentWithVectorStoresAsync but the upload-then-create-store
|
||||
// sequence routes through the FoundryAgent.CreateVectorStoreAsync extension (single call
|
||||
// that uploads, creates the store, and polls until ready). The resulting vector store id
|
||||
// is then wired to a versioned agent's FileSearch tool and queried for a known value.
|
||||
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("VectorStoreExtAgent");
|
||||
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.
|
||||
Do not answer a question unless you can find the answer using the File Search Tool.
|
||||
""";
|
||||
|
||||
// Non-versioned helper agent that owns the upload pipeline.
|
||||
var helperAgent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var helperFoundryAgent = this.WrapAsFoundryAgent(helperAgent);
|
||||
|
||||
var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt";
|
||||
File.WriteAllText(searchFilePath, "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457.");
|
||||
|
||||
VectorStore? vectorStore = null;
|
||||
FoundryAgent? versionedAgent = null;
|
||||
try
|
||||
{
|
||||
// Act — single agent-level helper call uploads, creates, and waits until ready.
|
||||
vectorStore = await helperFoundryAgent.CreateVectorStoreAsync(
|
||||
"WordCodeLookup_ExtensionVectorStore",
|
||||
new[] { searchFilePath });
|
||||
|
||||
Assert.NotNull(vectorStore);
|
||||
Assert.False(string.IsNullOrEmpty(vectorStore.Id));
|
||||
Assert.NotEqual(VectorStoreStatus.InProgress, vectorStore.Status);
|
||||
|
||||
// Wire the store id into a versioned agent's FileSearch tool to prove it is actually usable.
|
||||
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = { ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStore.Id]) },
|
||||
};
|
||||
|
||||
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
|
||||
AgentName,
|
||||
new ProjectsAgentVersionCreationOptions(definition));
|
||||
|
||||
versionedAgent = this._client.AsAIAgent(agentVersion);
|
||||
|
||||
// Assert.
|
||||
var result = await versionedAgent.RunAsync("Can you give me the documented code for 'banana'?");
|
||||
Assert.Contains("673457", result.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (versionedAgent is not null)
|
||||
{
|
||||
await this._client.AgentAdministrationClient.DeleteAgentAsync(versionedAgent.Name);
|
||||
}
|
||||
|
||||
// Cleanup the vector store via the new extension too.
|
||||
if (vectorStore is not null)
|
||||
{
|
||||
await helperFoundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
|
||||
}
|
||||
|
||||
File.Delete(searchFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "For manual testing only")]
|
||||
public async Task DeleteVectorStoreAsync_ViaAgentExtension_RemovesStoreAsync()
|
||||
{
|
||||
var agent = this._client.AsAIAgent(
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
var foundryAgent = this.WrapAsFoundryAgent(agent);
|
||||
|
||||
var filePath = Path.GetTempFileName() + ".txt";
|
||||
File.WriteAllText(filePath, "delete-store payload");
|
||||
|
||||
VectorStore? vectorStore = null;
|
||||
try
|
||||
{
|
||||
vectorStore = await foundryAgent.CreateVectorStoreAsync(
|
||||
"DeleteVectorStore_ExtensionTest",
|
||||
new[] { filePath });
|
||||
|
||||
// Act.
|
||||
var result = await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(vectorStore.Id, result.VectorStoreId);
|
||||
Assert.True(result.Deleted);
|
||||
vectorStore = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (vectorStore is not null)
|
||||
{
|
||||
await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the underlying <see cref="FoundryAgent"/> from an <see cref="AIAgent"/> handle
|
||||
/// returned by <c>AIProjectClient.AsAIAgent(model, instructions)</c>. The Mode 1 overload
|
||||
/// returns a <see cref="ChatClientAgent"/>; the extension forwarders we test live on
|
||||
/// <see cref="FoundryAgent"/>, so callers wanting them through this entry point need to
|
||||
/// reach for the FoundryAgent constructor instead. This helper makes the test setup
|
||||
/// consistent across the four IT scenarios.
|
||||
/// </summary>
|
||||
private FoundryAgent WrapAsFoundryAgent(AIAgent agent)
|
||||
{
|
||||
// The Mode 1 AsAIAgent overload returns ChatClientAgent rather than FoundryAgent; use
|
||||
// the FoundryAgent projectEndpoint+model+instructions ctor to get the same underlying
|
||||
// FoundryChatClient surfaced through a FoundryAgent typed handle.
|
||||
_ = agent;
|
||||
return new FoundryAgent(
|
||||
projectEndpoint: new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
|
||||
credential: TestAzureCliCredentials.CreateAzureCliCredential(),
|
||||
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
|
||||
instructions: "Be helpful.");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -11,7 +11,7 @@ namespace Foundry.IntegrationTests;
|
||||
public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputRunTests<FoundryVersionedAgentStructuredOutputFixture<CityInfo>>(() => new FoundryVersionedAgentStructuredOutputFixture<CityInfo>())
|
||||
{
|
||||
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.";
|
||||
private const string ResponseFormatNotSupported = "FoundryChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition.";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that response format provided at agent initialization is used when invoking RunAsync.
|
||||
@@ -41,7 +41,7 @@ public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputR
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// The type T provided to RunAsync<T> is ignored by FoundryChatClient and is only used
|
||||
/// for deserializing the agent response by AgentResponse<T>.Result.
|
||||
/// </remarks>
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)]
|
||||
|
||||
@@ -83,6 +83,72 @@ public sealed class A2AAgentTests : IDisposable
|
||||
Assert.Null(agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithOptions_InitializesPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var options = new A2AAgentOptions
|
||||
{
|
||||
Id = "options-id",
|
||||
Name = "options-name",
|
||||
Description = "options-description"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new A2AAgent(this._a2aClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("options-id", agent.Id);
|
||||
Assert.Equal("options-name", agent.Name);
|
||||
Assert.Equal("options-description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithOptions_IsolatesAgentFromOptionsMutation()
|
||||
{
|
||||
// Arrange
|
||||
var options = new A2AAgentOptions
|
||||
{
|
||||
Id = "original-id",
|
||||
Name = "Original Name",
|
||||
Description = "Original Description"
|
||||
};
|
||||
var agent = new A2AAgent(this._a2aClient, options);
|
||||
|
||||
// Act - mutate options after agent construction
|
||||
options.Id = "mutated-id";
|
||||
options.Name = "Mutated Name";
|
||||
options.Description = "Mutated Description";
|
||||
|
||||
// Assert - agent should retain original values
|
||||
Assert.Equal("original-id", agent.Id);
|
||||
Assert.Equal("Original Name", agent.Name);
|
||||
Assert.Equal("Original Description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullOptions_ThrowsArgumentNullException() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new A2AAgent(this._a2aClient, options: null!));
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithEmptyOptions_UsesBaseProperties()
|
||||
{
|
||||
// Act
|
||||
var agent = new A2AAgent(this._a2aClient, new A2AAgentOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.Id);
|
||||
Assert.NotEmpty(agent.Id);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithOptions_NullA2AClient_ThrowsArgumentNullException() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new A2AAgent(null!, new A2AAgentOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AllowsNonUserRoleMessagesAsync()
|
||||
{
|
||||
|
||||
+76
-1
@@ -31,7 +31,7 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_ReturnsAIAgent()
|
||||
public void AsAIAgent_ReturnsAIAgent()
|
||||
{
|
||||
// Act
|
||||
var agent = this._agentCard.AsAIAgent();
|
||||
@@ -165,6 +165,81 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentOptions_OverridesCardValues()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id",
|
||||
Name = "Custom Agent",
|
||||
Description = "Custom description"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = card.AsAIAgent(agentOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Custom Agent", agent.Name);
|
||||
Assert.Equal("Custom description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentOptions_FallsBackToCardValues()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = card.AsAIAgent(agentOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Card Agent", agent.Name);
|
||||
Assert.Equal("Card description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithEmptyAgentOptions_UsesCardValues()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = card.AsAIAgent(new A2AAgentOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Card Agent", agent.Name);
|
||||
Assert.Equal("Card description", agent.Description);
|
||||
}
|
||||
|
||||
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public Queue ResponsesToReturn { get; } = new();
|
||||
|
||||
+55
@@ -113,6 +113,61 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithAgentOptions_OverridesCardValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id",
|
||||
Name = "Custom Agent",
|
||||
Description = "Custom description"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Custom Agent", agent.Name);
|
||||
Assert.Equal("Custom description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithAgentOptions_FallsBackToCardValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Card Agent", agent.Name);
|
||||
Assert.Equal("Card description", agent.Description);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
|
||||
+44
-3
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
public sealed class A2AClientExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
public void AsAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
@@ -32,7 +32,7 @@ public sealed class A2AClientExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
public void AsAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange - use IA2AClient reference type to verify the extension method works with the interface
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
@@ -53,7 +53,7 @@ public sealed class A2AClientExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
|
||||
public void AsAIAgent_WithIA2AClient_ExposesClientViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
@@ -66,4 +66,45 @@ public sealed class A2AClientExtensionsTests
|
||||
Assert.NotNull(service);
|
||||
Assert.Same(a2aClient, service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithOptions_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
var options = new A2AAgentOptions
|
||||
{
|
||||
Id = "options-agent-id",
|
||||
Name = "Options Agent",
|
||||
Description = "Agent created with options"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("options-agent-id", agent.Id);
|
||||
Assert.Equal("Options Agent", agent.Name);
|
||||
Assert.Equal("Agent created with options", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithEmptyOptions_ReturnsA2AAgentWithDefaultProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent(new A2AAgentOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.NotNull(agent.Id);
|
||||
Assert.NotEmpty(agent.Id);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
}
|
||||
}
|
||||
|
||||
+191
-4
@@ -64,15 +64,29 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
var inboundBody = await inboundResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: at least one OUTBOUND request reached the fake transport, AND it carries the
|
||||
// foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent.
|
||||
// (We don't care about the inbound response shape — only that the agent's call to MEAI
|
||||
// triggered an outbound request whose UA reaches the sandbox boundary correctly.)
|
||||
// combined hosted segment foundry-hosting/agent-framework-dotnet/{version} on its
|
||||
// User-Agent. This matches Python's contract
|
||||
// (foundry-hosting/agent-framework-python/{version}, see
|
||||
// python/packages/core/agent_framework/_telemetry.py): a single combined segment when
|
||||
// hosted, never two separate ones. The bare agent-framework-dotnet/{version} segment
|
||||
// (from AgentFrameworkUserAgentPolicy in FoundryChatClient) must be upgraded in place
|
||||
// by HostedAgentUserAgentPolicy — never appear duplicated.
|
||||
Assert.True(this._outboundHandler!.Requests.Count > 0,
|
||||
$"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}");
|
||||
var outbound = this._outboundHandler.Requests[0];
|
||||
Assert.StartsWith(TestEndpoint, outbound.Uri);
|
||||
Assert.Contains("MEAI/", outbound.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", outbound.UserAgent);
|
||||
|
||||
// The bare agent-framework-dotnet/{v} segment must NOT appear separately when the
|
||||
// combined form is present — Python emits a single combined value when the hosted
|
||||
// prefix is registered, and .NET preserves that contract via the in-place upgrade in
|
||||
// HostedAgentUserAgentPolicy.
|
||||
var combinedIdx = outbound.UserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
var beforeCombined = outbound.UserAgent.Substring(0, combinedIdx);
|
||||
var afterCombined = outbound.UserAgent.Substring(combinedIdx + "foundry-hosting/agent-framework-dotnet/".Length);
|
||||
Assert.DoesNotContain("agent-framework-dotnet/", beforeCombined);
|
||||
Assert.DoesNotContain("agent-framework-dotnet/", afterCombined);
|
||||
}
|
||||
|
||||
private async Task StartHostedServerAsync()
|
||||
@@ -197,6 +211,179 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
return array?.Length ?? -1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Direct unit tests for HostedAgentUserAgentPolicy's in-place upgrade behavior.
|
||||
// These run the policy on a synthetic ClientPipeline (no hosting infrastructure)
|
||||
// so the upgrade logic itself can be asserted in isolation.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_UpgradesBareAgentFrameworkSegment_InPlaceAsync()
|
||||
{
|
||||
// Arrange: an upstream per-call policy stamps the bare agent-framework-dotnet/{version}
|
||||
// segment (matching what AgentFrameworkUserAgentPolicy would write in non-hosted code).
|
||||
// Then HostedAgentUserAgentPolicy runs and must REPLACE that segment with the combined
|
||||
// foundry-hosting/agent-framework-dotnet/{version} form, not append a duplicate.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: combined form is present; bare form is gone (no duplicate agent-framework segment).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent);
|
||||
var ua = handler.LastUserAgent!;
|
||||
var firstAgentFramework = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(firstAgentFramework >= 0, "Expected agent-framework-dotnet segment.");
|
||||
var secondAgentFramework = ua.IndexOf("agent-framework-dotnet/", firstAgentFramework + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, secondAgentFramework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_AppendsCombined_WhenNoBareSegmentPresentAsync()
|
||||
{
|
||||
// Arrange: nothing upstream stamps the bare segment. Hosted policy should append the
|
||||
// full combined segment to whatever User-Agent is on the wire.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_IsIdempotent_WhenCombinedSegmentAlreadyPresentAsync()
|
||||
{
|
||||
// Arrange: upstream pre-populates the combined segment (simulating a retry or duplicate
|
||||
// registration). Hosted policy must not re-append.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: exactly one occurrence of "foundry-hosting/agent-framework-dotnet/" segment.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
var first = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0);
|
||||
var second = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", first + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_ReplacesDifferentVersionCombinedSegment_InPlaceAsync()
|
||||
{
|
||||
// Q-D regression: when the User-Agent already carries the COMBINED hosted form with a
|
||||
// different version (e.g. an older registration or caller-supplied baseline), the policy
|
||||
// must replace the entire combined span — not just the bare suffix — so we never emit
|
||||
// the malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` shape.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/0.0.1 MEAI/10.5.1"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: no doubled foundry-hosting/ prefix.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/foundry-hosting/", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
|
||||
// The combined segment must appear exactly once, and the trailing MEAI segment must be
|
||||
// preserved in place (i.e. the policy only rewrote the combined span, not anything after it).
|
||||
var firstCombined = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(firstCombined >= 0);
|
||||
var secondCombined = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", firstCombined + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, secondCombined);
|
||||
Assert.Contains(" MEAI/10.5.1", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
|
||||
// And the version that survives must be the runtime supplement value's version, not 0.0.1.
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet/0.0.1", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class InspectingHandler : HttpClientHandler
|
||||
{
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SetUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _value;
|
||||
public SetUserAgentPolicy(string value) => this._value = value;
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
|
||||
+111
-4
@@ -23,9 +23,9 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AzureAIProjectChatClientExtensions"/> class.
|
||||
/// Unit tests for the <see cref="AIProjectClientExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
public sealed class AIProjectClientExtensionsTests
|
||||
{
|
||||
#region AsAIAgent(AIProjectClient, model, instructions) Tests
|
||||
|
||||
@@ -71,7 +71,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
Assert.Equal("test-agent", agent.Name);
|
||||
Assert.Equal("A test agent", agent.Description);
|
||||
Assert.NotNull(agent.GetService<IChatClient>());
|
||||
Assert.Null(agent.GetService<AIProjectClient>());
|
||||
// After the FoundryChatClient consolidation the inner chat-client now exposes the
|
||||
// AIProjectClient via GetService — Foundry callers can walk to the project client from
|
||||
// the agent without holding their own reference. (Previously this path returned null
|
||||
// because AsAIAgent(model, instructions) skipped the decorator entirely.)
|
||||
Assert.NotNull(agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -123,7 +127,10 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("options-agent", agent.Name);
|
||||
Assert.Equal("Agent from options", agent.Description);
|
||||
Assert.Null(agent.GetService<AIProjectClient>());
|
||||
// After the FoundryChatClient consolidation the inner chat-client now exposes the
|
||||
// AIProjectClient via GetService — see twin assertion in
|
||||
// AsAIAgent_Rapi_WithModelAndInstructions_CreatesChatClientAgent for the rationale.
|
||||
Assert.NotNull(agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -185,6 +192,106 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
Assert.True(userAgentFound, "MEAI user-agent header was not found in any request");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the non-versioned AsAIAgent overload now wraps with FoundryChatClient
|
||||
/// (regression-prevention for the previously-untagged extension path).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_Rapi_WithModelAndInstructions_ExposesFoundryChatClientAndProviderName()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = client.AsAIAgent("gpt-4o-mini", "You are helpful.");
|
||||
|
||||
// Assert: FoundryChatClient is internal-sealed and reachable via GetService<IChatClient>().
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
|
||||
// Provider tag is "microsoft.foundry" (previously this path had no Foundry tag at all).
|
||||
var metadata = chatClient!.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.Equal("gpt-4o-mini", metadata.DefaultModelId);
|
||||
|
||||
// Reaching the FoundryChatClient by type (via InternalsVisibleTo).
|
||||
Assert.NotNull(agent.GetService<FoundryChatClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the options-based non-versioned AsAIAgent overload now wraps with FoundryChatClient.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_Rapi_WithOptions_ExposesFoundryChatClientAndProviderName()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
ChatClientAgentOptions options = new()
|
||||
{
|
||||
Name = "options-agent",
|
||||
ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini", Instructions = "x" },
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = client.AsAIAgent(options);
|
||||
|
||||
// Assert
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
var metadata = chatClient!.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.NotNull(agent.GetService<FoundryChatClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the non-versioned AsAIAgent overload stamps the
|
||||
/// agent-framework-dotnet/{version} segment on outbound requests via the new
|
||||
/// AgentFrameworkUserAgentPolicy registered by FoundryChatClient.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AsAIAgent_Rapi_WithModelAndInstructions_StampsAgentFrameworkUserAgentSegmentAsync()
|
||||
{
|
||||
bool afSeen = false;
|
||||
using HttpHandlerAssert httpHandler = new(request =>
|
||||
{
|
||||
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
|
||||
{
|
||||
foreach (string value in values)
|
||||
{
|
||||
if (value.Contains("agent-framework-dotnet/"))
|
||||
{
|
||||
afSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), 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(afSeen, "Expected agent-framework-dotnet/{version} segment on outbound requests from AsAIAgent(model, instructions).");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AsAIAgent(AIProjectClient, ProjectsAgentRecord) Tests
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the framework-wide <see cref="AgentFrameworkUserAgentPolicy"/>. The policy stamps
|
||||
/// <c>agent-framework-dotnet/{version}</c> onto the outgoing <c>User-Agent</c> header of every
|
||||
/// request made through a Foundry chat client and is registered automatically by
|
||||
/// <c>FoundryChatClient</c> via the MEAI <c>OpenAIRequestPolicies</c> hook.
|
||||
/// </summary>
|
||||
public sealed class AgentFrameworkUserAgentPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_AddsAgentFrameworkSegment_ToOutgoingRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, handler.Count);
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_DoesNotStampMeaiSegmentAsync()
|
||||
{
|
||||
// Arrange: the AF policy must only contribute the agent-framework-dotnet segment.
|
||||
// The MEAI/{version} segment is contributed by the MEAI-shipped policy at a different
|
||||
// layer; this policy must not duplicate or replace it.
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("MEAI/", handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_PreservesExistingUserAgent_WhenAppendingAsync()
|
||||
{
|
||||
// Arrange: a per-call policy upstream that pre-populates the User-Agent header. The AF
|
||||
// policy must read the existing value and append (not overwrite) the agent-framework
|
||||
// segment so both stay reachable on the wire. (The exact separator the HTTP transport
|
||||
// emits between multi-value User-Agent entries is comma per RFC 7230; this test does
|
||||
// not assert on the separator character because that is a transport detail.)
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SeedUserAgentPolicy("existing-app/1.0"), AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: both segments survive to the wire.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("existing-app/1.0", handler.LastUserAgent);
|
||||
Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentFrameworkUserAgentPolicy_IsIdempotent_DoesNotDoubleStampAsync()
|
||||
{
|
||||
// Arrange: register the same policy twice on the same pipeline. The second application
|
||||
// must detect the segment is already present and not append it again. Guards against
|
||||
// double-stamping on retries or duplicate registration.
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance, AgentFrameworkUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: exactly one occurrence of "agent-framework-dotnet/".
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
var ua = handler.LastUserAgent!;
|
||||
var first = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0, "Expected at least one agent-framework-dotnet segment.");
|
||||
var second = ua.IndexOf("agent-framework-dotnet/", first + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentFrameworkUserAgentPolicy_ExposesSingletonInstance()
|
||||
{
|
||||
// Two reads of the static property must return the same instance. The policy is stateless
|
||||
// and shared; allocating a fresh instance per registration site would bloat memory and
|
||||
// defeat the dedup logic in OpenAIRequestPoliciesReflection.AddPolicyIfMissing.
|
||||
var first = AgentFrameworkUserAgentPolicy.Instance;
|
||||
var second = AgentFrameworkUserAgentPolicy.Instance;
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentFrameworkUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
|
||||
{
|
||||
// The policy emits "agent-framework-dotnet/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
|
||||
// If the assembly metadata stops being readable, the policy falls back to "agent-framework-dotnet"
|
||||
// without a version, which is a measurable telemetry regression.
|
||||
var attr = typeof(AgentFrameworkUserAgentPolicy).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SeedUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _value;
|
||||
public SeedUserAgentPolicy(string value) => this._value = value;
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
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.Foundry.UnitTests;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public class AzureAIProjectChatClientTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that after the first RunAsync, the session's ConversationId is set from the
|
||||
/// response, and subsequent requests include that conversation ID automatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesDefaultConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var responsesRequestCount = 0;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
responsesRequestCount++;
|
||||
|
||||
// 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("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
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
|
||||
Assert.Equal(2, responsesRequestCount);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using OpenAI.Files;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the file and vector-store forwarder extensions on <see cref="FoundryAgent"/>
|
||||
/// declared in <see cref="FoundryAgentExtensions"/>. The forwarders are thin shims over the
|
||||
/// inner <see cref="FoundryChatClient"/>, so coverage focuses on (a) request shape (the agent
|
||||
/// path reaches the same wire as a direct chat-client call), (b) null/missing-FoundryChatClient
|
||||
/// handling, and (c) returns the same payload the chat client would.
|
||||
/// </summary>
|
||||
public sealed class FoundryAgentExtensionsTests
|
||||
{
|
||||
private static readonly Uri s_testProjectEndpoint = new("https://test.openai.azure.com/");
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
// Arrange — agent built via the Responses Agent (Mode 1) projectEndpoint+model+instructions
|
||||
// ctor wires a FoundryChatClient inside that the extension can resolve via GetService.
|
||||
var sawPostToFiles = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
sawPostToFiles = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(FakeFileJson("file_via_agent"), 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(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"fae-{Guid.NewGuid():N}.txt");
|
||||
System.IO.File.WriteAllText(path, "hello");
|
||||
|
||||
try
|
||||
{
|
||||
// Act — call the forwarder on the agent.
|
||||
var result = await agent.UploadFileAsync(path, FileUploadPurpose.Assistants);
|
||||
|
||||
// Assert
|
||||
Assert.True(sawPostToFiles, "POST to /files must reach the wire through the agent forwarder.");
|
||||
Assert.Equal("file_via_agent", result.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
var sawDelete = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Delete && req.RequestUri!.AbsolutePath.Contains("/files/", StringComparison.Ordinal))
|
||||
{
|
||||
sawDelete = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"id\":\"file_abc\",\"object\":\"file\",\"deleted\":true}", 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(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var result = await agent.DeleteFileAsync("file_abc");
|
||||
|
||||
Assert.True(sawDelete);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
var sawVectorStorePost = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal))
|
||||
{
|
||||
sawVectorStorePost = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(FakeVectorStoreJson("vs_via_agent", "kb"), 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(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var store = await agent.CreateVectorStoreAsync("kb", Array.Empty<string>());
|
||||
|
||||
Assert.True(sawVectorStorePost);
|
||||
Assert.Equal("vs_via_agent", store.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_Forwards_ToInnerFoundryChatClient_Async()
|
||||
{
|
||||
var sawDelete = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Delete && req.RequestUri!.AbsolutePath.Contains("/vector_stores/", StringComparison.Ordinal))
|
||||
{
|
||||
sawDelete = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"id\":\"vs_abc\",\"object\":\"vector_store.deleted\",\"deleted\":true}", 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(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var agent = new FoundryAgent(
|
||||
projectEndpoint: s_testProjectEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.",
|
||||
clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
await agent.DeleteVectorStoreAsync("vs_abc");
|
||||
|
||||
Assert.True(sawDelete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.UploadFileAsync(null!, "x", FileUploadPurpose.Assistants));
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.DeleteFileAsync(null!, "file_abc"));
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.CreateVectorStoreAsync(null!, "kb", Array.Empty<string>()));
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryAgentExtensions.DeleteVectorStoreAsync(null!, "vs_abc"));
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
private static string FakeFileJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}";
|
||||
|
||||
private static string FakeVectorStoreJson(string id, string name)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"completed\",\"last_active_at\":1700000000}}";
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
@@ -352,18 +351,24 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_UserAgentHeaderAddedToRequestsAsync()
|
||||
public async Task Constructor_AgentFrameworkUserAgentHeaderAddedToRequestsAsync()
|
||||
{
|
||||
bool userAgentFound = false;
|
||||
// After the FoundryChatClient consolidation, every outbound request from a
|
||||
// FoundryAgent-built chat client carries the new agent-framework-dotnet/{version}
|
||||
// segment (stamped by AgentFrameworkUserAgentPolicy registered via the MEAI
|
||||
// OpenAIRequestPolicies hook). The local MEAI/{version} stamp was removed because
|
||||
// MEAI 10.5.1 stamps that itself; this test only verifies the framework-wide segment
|
||||
// that the Foundry package now guarantees.
|
||||
bool agentFrameworkUserAgentFound = false;
|
||||
using HttpHandlerAssert httpHandler = new(request =>
|
||||
{
|
||||
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
|
||||
if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable<string>? values))
|
||||
{
|
||||
foreach (string value in values)
|
||||
{
|
||||
if (value.StartsWith("MEAI/", StringComparison.OrdinalIgnoreCase))
|
||||
if (value.Contains("agent-framework-dotnet/"))
|
||||
{
|
||||
userAgentFound = true;
|
||||
agentFrameworkUserAgentFound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,7 +401,7 @@ public class FoundryAgentTests
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session);
|
||||
|
||||
Assert.True(userAgentFound, "Expected MEAI user-agent header to be present in requests.");
|
||||
Assert.True(agentFrameworkUserAgentFound, "Expected agent-framework-dotnet user-agent segment to be present on outbound requests.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -434,6 +439,9 @@ public class FoundryAgentTests
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
|
||||
{
|
||||
// Behavior change: FoundryAgent no longer caches a ProjectOpenAIClient. Callers
|
||||
// retrieve it from the AIProjectClient themselves
|
||||
// (agent.GetService<AIProjectClient>()!.GetProjectOpenAIClient()).
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.Null(agent.GetService<ProjectOpenAIClient>());
|
||||
@@ -442,6 +450,10 @@ public class FoundryAgentTests
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull()
|
||||
{
|
||||
// Behavior change: after Plan #2's Agent Endpoint mode (Mode 3) AIProjectClient materialization, the
|
||||
// agent-endpoint constructor now derives a project-level AIProjectClient from the
|
||||
// parsed project root URL and surfaces it via GetService. Previously this returned
|
||||
// null because no AIProjectClient was constructed for hosted-agent-endpoint agents.
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.NotNull(agent.GetService<AIProjectClient>());
|
||||
@@ -450,6 +462,7 @@ public class FoundryAgentTests
|
||||
[Fact]
|
||||
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
|
||||
{
|
||||
// See AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull for rationale.
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
@@ -611,6 +624,57 @@ public class FoundryAgentTests
|
||||
Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_ExposesFoundryProviderName_OnChatClientMetadata()
|
||||
{
|
||||
// Behavior change: after the FoundryChatClient consolidation, the agent-endpoint path
|
||||
// now wraps with FoundryChatClient in the Agent Endpoint mode (Mode 3) and stamps the microsoft.foundry provider
|
||||
// name. Previously this path used a bare AsIChatClient() with no Foundry-specific
|
||||
// decorator, so the provider name defaulted to whatever MEAI surfaces. This guards the
|
||||
// new behavior.
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
var metadata = agent.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_StampsAgentFrameworkUserAgentSegmentAsync()
|
||||
{
|
||||
// Behavior change: after the FoundryChatClient consolidation, every outbound request
|
||||
// from the agent-endpoint constructor carries the agent-framework-dotnet/{version}
|
||||
// segment via AgentFrameworkUserAgentPolicy. Previously this path had no
|
||||
// agent-framework branding at all.
|
||||
bool afSeen = false;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
if (req.Headers.TryGetValues("User-Agent", out var values))
|
||||
{
|
||||
foreach (string v in values)
|
||||
{
|
||||
if (v.Contains("agent-framework-dotnet/"))
|
||||
{
|
||||
afSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.True(afSeen, "Expected agent-framework-dotnet/{version} segment on the agent-endpoint outbound User-Agent.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync()
|
||||
{
|
||||
@@ -666,82 +730,22 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_PreservesUserAgentApplicationId()
|
||||
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
|
||||
{
|
||||
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
|
||||
// application-id stamp in the outbound request. Verify the value is propagated onto the
|
||||
// caller's options bag and that the materialized AIProjectClient is reachable so
|
||||
// downstream conversation/file/vector-store operations can pick the application id up.
|
||||
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
|
||||
AIProjectClient? aiProjectClient = agent.GetService<AIProjectClient>();
|
||||
Assert.NotNull(aiProjectClient);
|
||||
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProjectClientOptions_NullCallerOptions_ReturnsNull()
|
||||
{
|
||||
Assert.Null(FoundryAgent.CreateProjectClientOptions(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProjectClientOptions_CarriesPipelineSettingsAndUserAgent()
|
||||
{
|
||||
// Arrange
|
||||
var transport = new FakePipelineTransport();
|
||||
var retryPolicy = new FakeRetryPolicy();
|
||||
var messageLoggingPolicy = new FakeMessageLoggingPolicy();
|
||||
var clientLoggingOptions = new ClientLoggingOptions { EnableLogging = false };
|
||||
var networkTimeout = TimeSpan.FromSeconds(42);
|
||||
|
||||
ProjectOpenAIClientOptions callerOptions = new()
|
||||
{
|
||||
UserAgentApplicationId = "my-app-id",
|
||||
Transport = transport,
|
||||
RetryPolicy = retryPolicy,
|
||||
MessageLoggingPolicy = messageLoggingPolicy,
|
||||
ClientLoggingOptions = clientLoggingOptions,
|
||||
NetworkTimeout = networkTimeout,
|
||||
};
|
||||
|
||||
// Act
|
||||
AIProjectClientOptions? projectOptions = FoundryAgent.CreateProjectClientOptions(callerOptions);
|
||||
|
||||
// Assert: every settable pipeline behavior the caller configured is forwarded
|
||||
// onto the project-level options bag, not silently dropped.
|
||||
Assert.NotNull(projectOptions);
|
||||
Assert.Equal("my-app-id", projectOptions!.UserAgentApplicationId);
|
||||
Assert.Same(transport, projectOptions.Transport);
|
||||
Assert.Same(retryPolicy, projectOptions.RetryPolicy);
|
||||
Assert.Same(messageLoggingPolicy, projectOptions.MessageLoggingPolicy);
|
||||
Assert.Same(clientLoggingOptions, projectOptions.ClientLoggingOptions);
|
||||
Assert.Equal(networkTimeout, projectOptions.NetworkTimeout);
|
||||
}
|
||||
|
||||
private sealed class FakeRetryPolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNext(message, pipeline, currentIndex);
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private sealed class FakeMessageLoggingPolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNext(message, pipeline, currentIndex);
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private sealed class FakePipelineTransport : PipelineTransport
|
||||
{
|
||||
protected override PipelineMessage CreateMessageCore() => throw new NotSupportedException();
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new NotSupportedException();
|
||||
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseAgentEndpoint tests
|
||||
@@ -824,13 +828,13 @@ public class FoundryAgentTests
|
||||
private readonly string _value;
|
||||
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the internal <see cref="FoundryChatClient"/>. Covers the three construction
|
||||
/// modes (Responses Agent, Prompt Agent, Agent Endpoint), the GetService
|
||||
/// returns per mode, the metadata-tagging contract, the agent-framework user-agent registration,
|
||||
/// the Agent Endpoint mode (Mode 3) URL parsing happy and error paths, and end-to-end behavior through the public
|
||||
/// <c>AsAIAgent(AgentReference)</c> extension that constructs a FoundryChatClient internally.
|
||||
/// </summary>
|
||||
public sealed class FoundryChatClientTests
|
||||
{
|
||||
#region the Responses Agent mode (Mode 1): Responses Agent (AIProjectClient + modelId)
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_StampsFoundryProviderName()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
// Assert
|
||||
var metadata = chatClient.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.Equal("gpt-4o-mini", metadata.DefaultModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ExposesAIProjectClient_ViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
// Assert
|
||||
Assert.Same(projectClient, chatClient.GetService<AIProjectClient>());
|
||||
// ProjectOpenAIClient is intentionally NOT exposed via GetService — callers retrieve
|
||||
// it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()).
|
||||
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ReturnsNullForAgentSpecificServices()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
// Assert
|
||||
Assert.Null(chatClient.GetService<AgentReference>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
|
||||
// No agent name exists in the Responses Agent mode (Mode 1) — only the Prompt Agent mode (Mode 2) (from AgentReference.Name) and the Agent Endpoint mode (Mode 3)
|
||||
// (parsed from URL) populate FoundryChatClient.AgentName.
|
||||
Assert.Null(chatClient.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ThrowsOnNullProjectClient()
|
||||
=> Assert.Throws<ArgumentNullException>(() => new FoundryChatClient(aiProjectClient: null!, "gpt-4o-mini"));
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_ThrowsOnEmptyModelId()
|
||||
=> Assert.Throws<ArgumentException>(() => new FoundryChatClient(CreateProjectClient(), modelId: ""));
|
||||
|
||||
#endregion
|
||||
|
||||
#region the Prompt Agent mode (Mode 2): Prompt Agent (direct unit tests)
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_StampsFoundryProviderNameAndDefaultModelId()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null);
|
||||
|
||||
// Assert
|
||||
var metadata = chatClient.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
Assert.Equal("gpt-4o", metadata.DefaultModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_ExposesAgentReference_ViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
|
||||
// Assert
|
||||
Assert.Same(agentRef, chatClient.GetService<AgentReference>());
|
||||
Assert.Same(projectClient, chatClient.GetService<AIProjectClient>());
|
||||
// ProjectOpenAIClient is intentionally NOT exposed via GetService — see comment in
|
||||
// Mode1_ResponsesAgent_ExposesAIProjectClient_ViaGetService.
|
||||
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
|
||||
// Version/Record were not provided via this ctor.
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_PopulatesAgentNameFromAgentReference()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("my-server-side-agent", "1");
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
|
||||
// Assert: AgentName is general-purpose across the Prompt Agent (Mode 2) and Agent Endpoint (Mode 3) modes. In the Prompt Agent mode (Mode 2) it mirrors
|
||||
// AgentReference.Name so callers have a uniform handle regardless of construction mode.
|
||||
Assert.Equal("my-server-side-agent", chatClient.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_AllowsNullDefaultModelIdAndBaseChatOptions()
|
||||
{
|
||||
// Arrange
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
|
||||
// Act + Assert: must not throw; defaultModelId and baseChatOptions are optional.
|
||||
var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
Assert.NotNull(chatClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_ThrowsOnNullAgentReference()
|
||||
=> Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryChatClient(CreateProjectClient(), agentReference: null!, defaultModelId: null, baseChatOptions: null));
|
||||
|
||||
#endregion
|
||||
|
||||
#region the Prompt Agent mode (Mode 2): Prompt Agent end-to-end round-trip via AsAIAgent(AgentReference) extension
|
||||
|
||||
// The end-to-end tests below exercise the same FoundryChatClient mode-2 behaviors above,
|
||||
// but through the public AsAIAgent(AgentReference) extension that constructs a FoundryChatClient
|
||||
// internally. They focus on the conversation-id handling that only manifests through the
|
||||
// ChatClientAgentSession surface, which requires a fully assembled agent rather than a bare
|
||||
// chat client.
|
||||
|
||||
/// <summary>
|
||||
/// Verify that after the first RunAsync, the session's ConversationId is set from the
|
||||
/// response, and subsequent requests include that conversation ID automatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesDefaultConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var responsesRequestCount = 0;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
responsesRequestCount++;
|
||||
|
||||
// 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("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
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
|
||||
Assert.Equal(2, responsesRequestCount);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("conv_12345", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EndToEnd_AgentReference_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
AIProjectClient projectClient = new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region the Agent Endpoint mode (Mode 3): Agent Endpoint
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ParsesAgentNameFromUrl()
|
||||
{
|
||||
// Arrange + Act
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", chatClient.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_StampsFoundryProviderName()
|
||||
{
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
// Assert
|
||||
var metadata = chatClient.GetService<ChatClientMetadata>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("microsoft.foundry", metadata!.ProviderName);
|
||||
// No model id is knowable from the URL alone.
|
||||
Assert.Null(metadata.DefaultModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ExposesProjectOpenAIClientAndAIProjectClient()
|
||||
{
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
// Assert
|
||||
// ProjectOpenAIClient is intentionally NOT exposed via GetService — callers retrieve
|
||||
// it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()).
|
||||
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
|
||||
// After the materialization change, the Agent Endpoint mode (Mode 3) also exposes a working AIProjectClient
|
||||
// built from the parsed project root. This makes the helper surface symmetric across
|
||||
// all three construction modes.
|
||||
Assert.NotNull(chatClient.GetService<AIProjectClient>());
|
||||
Assert.Null(chatClient.GetService<AgentReference>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_MaterializedAIProjectClient_TargetsParsedProjectRoot()
|
||||
{
|
||||
// The Agent Endpoint mode (Mode 3) ctor must derive the project root from the agent endpoint URL and
|
||||
// construct the AIProjectClient against that root, NOT the agent endpoint itself.
|
||||
var agentEndpoint = new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai");
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: agentEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
var aiProjectClient = chatClient.GetService<AIProjectClient>();
|
||||
Assert.NotNull(aiProjectClient);
|
||||
// AIProjectClient does not expose its endpoint publicly, so we rely on reflection on
|
||||
// the well-known private field. If the SDK field shape changes this guard fails loudly.
|
||||
var field = typeof(AIProjectClient).GetField("_endpoint", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(field);
|
||||
var actualEndpoint = (Uri)field!.GetValue(aiProjectClient!)!;
|
||||
Assert.Equal("https://example.com/api/projects/myproj", actualEndpoint.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_MaterializedAIProjectClient_IsReusedAcrossGetServiceCalls()
|
||||
{
|
||||
// Repeated GetService<AIProjectClient>() calls must return the same instance — the
|
||||
// materialized client is cached in the existing _aiProjectClient field, not built on
|
||||
// demand each call.
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: null);
|
||||
|
||||
var first = chatClient.GetService<AIProjectClient>();
|
||||
var second = chatClient.GetService<AIProjectClient>();
|
||||
Assert.NotNull(first);
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode1_ResponsesAgent_AIProjectClient_IsTheSuppliedInstance()
|
||||
{
|
||||
// Regression check: the Responses Agent mode (Mode 1) must continue to expose the AIProjectClient the caller
|
||||
// supplied via the constructor, NOT a freshly-materialized one.
|
||||
var supplied = CreateProjectClient();
|
||||
var chatClient = new FoundryChatClient(supplied, "gpt-4o-mini");
|
||||
Assert.Same(supplied, chatClient.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_PromptAgent_AIProjectClient_IsTheSuppliedInstance()
|
||||
{
|
||||
// Regression check: the Prompt Agent mode (Mode 2) must continue to expose the AIProjectClient the caller
|
||||
// supplied via the constructor.
|
||||
var supplied = CreateProjectClient();
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
var chatClient = new FoundryChatClient(supplied, agentRef, defaultModelId: null, baseChatOptions: null);
|
||||
Assert.Same(supplied, chatClient.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ThrowsOnNullEndpoint()
|
||||
=> Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryChatClient(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider(), clientOptions: null));
|
||||
|
||||
[Fact]
|
||||
public void Mode3_AgentEndpoint_ThrowsOnNullCredential()
|
||||
=> Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: null!,
|
||||
clientOptions: null));
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseAgentEndpoint URL parsing
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_HappyPath_ReturnsAgentNameAndProjectRoot()
|
||||
{
|
||||
// Act
|
||||
var (agentName, projectRoot) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", agentName);
|
||||
Assert.Equal("https://example.com/api/projects/myproj", projectRoot.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_TolerantOfTrailingSlash()
|
||||
{
|
||||
// Act
|
||||
var (agentName, _) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai/"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", agentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_TolerantOfCaseDifferencesOnAgentsSegment()
|
||||
{
|
||||
// Act
|
||||
var (agentName, _) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/AGENTS/myagent/endpoint/protocols/openai"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("myagent", agentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_StripsQueryAndFragment()
|
||||
{
|
||||
// Act
|
||||
var (_, projectRoot) = FoundryChatClient.ParseAgentEndpoint(
|
||||
new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai?api-version=v1#frag"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, projectRoot.Query);
|
||||
Assert.Equal(string.Empty, projectRoot.Fragment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_ThrowsOnMissingAgentsSegment()
|
||||
=> Assert.Throws<ArgumentException>(() =>
|
||||
FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/anyseg/myagent/endpoint/protocols/openai")));
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_ThrowsOnWrongSuffix()
|
||||
=> Assert.Throws<ArgumentException>(() =>
|
||||
FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/agents/myagent/wrong/suffix")));
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_ThrowsOnNullUri()
|
||||
=> Assert.Throws<ArgumentNullException>(() => FoundryChatClient.ParseAgentEndpoint(null!));
|
||||
|
||||
#endregion
|
||||
|
||||
#region AgentFrameworkUserAgentPolicy registration + dedup
|
||||
|
||||
[Fact]
|
||||
public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies()
|
||||
{
|
||||
// Arrange + Act: constructing a FoundryChatClient should register the
|
||||
// AgentFrameworkUserAgentPolicy on the inner chat client's OpenAIRequestPolicies.
|
||||
var chatClient = new FoundryChatClient(CreateProjectClient(), "gpt-4o-mini");
|
||||
|
||||
// Assert: the inner chat client (MEAI's OpenAIResponsesChatClient) exposes
|
||||
// OpenAIRequestPolicies via GetService, and our policy is present in its entries.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClients_OnSharedInner()
|
||||
{
|
||||
// Arrange: construct via the ProjectsAgentVersion mode-2 variant, which chains via
|
||||
// :this(...) into the AgentReference ctor. If the policy registration code were
|
||||
// inadvertently called twice along the chain, we would see 2 entries.
|
||||
var projectClient = CreateProjectClient();
|
||||
var agentVersion = ModelReaderWriter.Read<ProjectsAgentVersion>(
|
||||
BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
|
||||
|
||||
// Act
|
||||
var chatClient = new FoundryChatClient(projectClient, agentVersion, baseChatOptions: null);
|
||||
|
||||
// Assert: even though the version variant funnels through the AgentReference ctor
|
||||
// via :this(...), the policy is registered exactly once on the inner pipeline.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
Assert.Same(agentVersion, chatClient.GetService<ProjectsAgentVersion>());
|
||||
Assert.NotNull(chatClient.GetService<AgentReference>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static AIProjectClient CreateProjectClient()
|
||||
=> new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(new HttpClient()) });
|
||||
|
||||
private static int EntriesCount(OpenAIRequestPolicies policies)
|
||||
{
|
||||
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(field);
|
||||
var arr = (Array)field!.GetValue(policies)!;
|
||||
return arr.Length;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
+660
@@ -0,0 +1,660 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using OpenAI.Files;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the file and vector-store helper methods on <see cref="FoundryChatClient"/>.
|
||||
/// Covers all four methods across the three FoundryChatClient construction modes plus argument
|
||||
/// validation, cancellation, and request-body shape on the wire.
|
||||
/// </summary>
|
||||
public sealed class FoundryChatClientVectorStoreTests
|
||||
{
|
||||
// ----- Construction helpers shared by every test in this file -----
|
||||
|
||||
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode1(string modelId = "gpt-4o-mini", string? responseBody = null)
|
||||
{
|
||||
var recorder = new RequestRecorder(responseBody);
|
||||
#pragma warning disable CA5399
|
||||
var httpClient = new HttpClient(recorder);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
return (new FoundryChatClient(projectClient, modelId), recorder);
|
||||
}
|
||||
|
||||
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode2(string? responseBody = null)
|
||||
{
|
||||
var recorder = new RequestRecorder(responseBody);
|
||||
#pragma warning disable CA5399
|
||||
var httpClient = new HttpClient(recorder);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var agentRef = new AgentReference("agent-name", "1");
|
||||
return (new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null), recorder);
|
||||
}
|
||||
|
||||
private static string MakeTempFile(string contents = "hello world")
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"fcc-test-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ----- UploadFileAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Mode1_UploadsViaProjectOpenAIClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileJson("file_abc"));
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
|
||||
|
||||
Assert.Equal("file_abc", result.Id);
|
||||
Assert.NotEmpty(recorder.Requests);
|
||||
Assert.EndsWith("/files", recorder.Requests[0].PathAndQuery.TrimEnd('/').Split('?')[0]);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Mode2_UploadsViaProjectOpenAIClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileJson("file_xyz"));
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
|
||||
Assert.Equal("file_xyz", result.Id);
|
||||
Assert.Contains(recorder.Requests, r => r.PathAndQuery.Contains("/files"));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_Mode3_UploadsViaMaterializedProjectClientAsync()
|
||||
{
|
||||
// Q-E: Mode 3 (Agent Endpoint) now honors caller-supplied transports via
|
||||
// ProjectOpenAIClientOptions.Transport, so we can use a fake transport here instead of
|
||||
// depending on DNS/network availability against example.com.
|
||||
var sawUpload = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
sawUpload = true;
|
||||
return MakeJsonResponse(FakeFileJson("file_mode3"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var chatClient = new FoundryChatClient(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
clientOptions: new ProjectOpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, CancellationToken.None);
|
||||
Assert.True(sawUpload);
|
||||
Assert.Equal("file_mode3", result.Id);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_NullFilePath_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
chatClient.UploadFileAsync(null!, FileUploadPurpose.Assistants));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_FileNotFound_ThrowsFileNotFoundExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(() =>
|
||||
chatClient.UploadFileAsync(missing, FileUploadPurpose.Assistants));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadFileAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Cancellation propagation through the OpenAI SDK pipeline surfaces different exception
|
||||
// types depending on the framework target (OperationCanceledException on net10.0,
|
||||
// ObjectDisposedException at the transport layer on net472). Asserting on the exact
|
||||
// exception class is brittle; assert only that the call throws when the token is
|
||||
// pre-cancelled.
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeFileJson("file_abc"));
|
||||
var path = MakeTempFile();
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, cts.Token));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
// ----- DeleteFileAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_Mode1_CallsDeleteOnFileClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileDeletedJson("file_abc"));
|
||||
await chatClient.DeleteFileAsync("file_abc");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_Mode2_CallsDeleteOnFileClientAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileDeletedJson("file_xyz"));
|
||||
await chatClient.DeleteFileAsync("file_xyz");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_xyz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_NullId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteFileAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_EmptyId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteFileAsync(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Verify the cancellation token reaches the HTTP pipeline by having the handler
|
||||
// throw OperationCanceledException when the token is cancelled before the request.
|
||||
// This is more robust than asserting on the exact exception the SDK surfaces, which
|
||||
// depends on internal pipeline plumbing.
|
||||
var observedToken = CancellationToken.None;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
// We don't have direct access to the SDK's CancellationToken here; instead, sleep
|
||||
// briefly to give the caller's pre-cancellation a chance to be picked up by the
|
||||
// transport. If cancellation reached the pipeline, the await on this handler call
|
||||
// would surface OperationCanceledException; if not, the response is returned.
|
||||
await Task.Delay(50).ConfigureAwait(false);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(FakeFileDeletedJson("file_abc"), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
// Any throw is acceptable evidence that cancellation was honored. The SDK's exact
|
||||
// exception surface for pre-cancelled tokens is an implementation detail of
|
||||
// System.ClientModel's pipeline and may differ between versions.
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.DeleteFileAsync("file_abc", cts.Token));
|
||||
}
|
||||
|
||||
// ----- CreateVectorStoreAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_UploadsThenCreates_WithFileIds_ReturnsVectorStoreAsync()
|
||||
{
|
||||
// Each file POST returns a distinct file id; the recorder dispatches on URL to differentiate.
|
||||
var fileCount = 0;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
var body = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/files") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
fileCount++;
|
||||
return MakeJsonResponse(FakeFileJson($"file_{fileCount}"));
|
||||
}
|
||||
if (req.RequestUri.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
Assert.Contains("file_1", body);
|
||||
Assert.Contains("file_2", body);
|
||||
Assert.Contains("knowledge-base", body);
|
||||
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "knowledge-base"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var pathA = MakeTempFile("alpha");
|
||||
var pathB = MakeTempFile("beta");
|
||||
try
|
||||
{
|
||||
var store = await chatClient.CreateVectorStoreAsync("knowledge-base", new[] { pathA, pathB });
|
||||
Assert.Equal("vs_abc", store.Id);
|
||||
Assert.Equal(2, fileCount);
|
||||
}
|
||||
finally { File.Delete(pathA); File.Delete(pathB); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_WithExpiresAfter_SerializesLastActiveAtAnchorAsync()
|
||||
{
|
||||
string? vectorStoreBody = null;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: TimeSpan.FromDays(7));
|
||||
|
||||
Assert.NotNull(vectorStoreBody);
|
||||
Assert.Contains("\"expires_after\"", vectorStoreBody);
|
||||
Assert.Contains("\"last_active_at\"", vectorStoreBody);
|
||||
Assert.Contains("\"days\":7", vectorStoreBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_WithNullExpiresAfter_OmitsExpirationPolicyAsync()
|
||||
{
|
||||
string? vectorStoreBody = null;
|
||||
using var handler = new HttpHandlerAssert(async req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null);
|
||||
|
||||
Assert.NotNull(vectorStoreBody);
|
||||
Assert.DoesNotContain("\"expires_after\"", vectorStoreBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_EmptyFilesList_CreatesEmptyStoreAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_empty", name: "x"));
|
||||
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>());
|
||||
Assert.Equal("vs_empty", store.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_NullName_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
chatClient.CreateVectorStoreAsync(null!, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_NullFilePaths_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
chatClient.CreateVectorStoreAsync("x", filePaths: null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Same rationale as UploadFileAsync_HonorsCancellationAsync — assert only that any
|
||||
// exception is thrown on a pre-cancelled token.
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_x", "x"));
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null, cancellationToken: cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_PollsUntilStoreLeavesInProgress_Async()
|
||||
{
|
||||
// Q-A regression: when the create response returns status=in_progress, the helper must
|
||||
// poll GET /vector_stores/{id} until status changes before returning. Otherwise the
|
||||
// caller receives a half-built store.
|
||||
var pollCount = 0;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
|
||||
{
|
||||
// First response: status=in_progress.
|
||||
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: "in_progress")));
|
||||
}
|
||||
if (req.RequestUri.AbsolutePath.Contains("/vector_stores/vs_abc") && req.Method == HttpMethod.Get)
|
||||
{
|
||||
pollCount++;
|
||||
// Stay in_progress for two polls, then complete on the third.
|
||||
var status = pollCount < 3 ? "in_progress" : "completed";
|
||||
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: status)));
|
||||
}
|
||||
return Task.FromResult(MakeJsonResponse("{}"));
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>());
|
||||
|
||||
Assert.NotEqual(OpenAI.VectorStores.VectorStoreStatus.InProgress, store.Status);
|
||||
Assert.True(pollCount >= 3, $"Expected at least 3 GET polls before status leaves in_progress; saw {pollCount}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_PollingTimeout_ThrowsTimeoutExceptionAsync()
|
||||
{
|
||||
// Sergey #2: caller-supplied (or default) polling timeout must surface as TimeoutException
|
||||
// when the vector store never leaves InProgress. Mock keeps the store stuck and we pass
|
||||
// a tiny timeout; cancellation token stays unused so the only path that ends the loop
|
||||
// is the timeout check.
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_stuck", name: "x", status: "in_progress")));
|
||||
}
|
||||
return Task.FromResult(MakeJsonResponse("{}"));
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
|
||||
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null, pollingTimeout: TimeSpan.FromMilliseconds(500)));
|
||||
Assert.Contains("vs_stuck", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("in-progress", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_MidUploadFailure_DeletesAlreadyUploadedFilesAsync()
|
||||
{
|
||||
// Q-B regression: when the upload loop throws partway through (e.g. file 3 of 5 is
|
||||
// missing or the network fails), the helper must DELETE the already-uploaded files so
|
||||
// they do not accumulate as orphaned resources. The exception must still propagate.
|
||||
var uploadCount = 0;
|
||||
var deleted = new List<string>();
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
// DELETE first so we don't match the upload-collection /files path against this.
|
||||
if (req.Method == HttpMethod.Delete)
|
||||
{
|
||||
var segments = req.RequestUri!.AbsolutePath.Split('/');
|
||||
var fileId = segments[segments.Length - 1];
|
||||
deleted.Add(fileId);
|
||||
return MakeJsonResponse(FakeFileDeletedJson(fileId));
|
||||
}
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
uploadCount++;
|
||||
if (uploadCount == 3)
|
||||
{
|
||||
// 400 is non-retriable; the SDK retry policy ignores it. 5xx would trigger
|
||||
// retries and confuse the assertion on upload count.
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed-on-3\"}}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var paths = new[] { MakeTempFile("a"), MakeTempFile("b"), MakeTempFile("c"), MakeTempFile("d"), MakeTempFile("e") };
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.CreateVectorStoreAsync("knowledge-base", paths));
|
||||
|
||||
// Three upload attempts: two succeeded, the third threw.
|
||||
Assert.Equal(3, uploadCount);
|
||||
// The two successful uploads must have been deleted as part of best-effort cleanup.
|
||||
Assert.Equal(2, deleted.Count);
|
||||
Assert.Contains("file_1", deleted);
|
||||
Assert.Contains("file_2", deleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in paths)
|
||||
{
|
||||
File.Delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVectorStoreAsync_MidUploadFailure_CleanupSwallowsDeleteErrorsAsync()
|
||||
{
|
||||
// Q-B follow-on: if a cleanup DELETE itself fails, the helper must still propagate the
|
||||
// original upload exception — not the cleanup exception. The caller cares about the
|
||||
// upload failure; cleanup is best-effort.
|
||||
var uploadCount = 0;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Delete)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("{\"error\":{\"code\":\"DeleteFailed\",\"message\":\"cleanup-failed\"}}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
|
||||
{
|
||||
uploadCount++;
|
||||
if (uploadCount == 2)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed\"}}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
|
||||
}
|
||||
return MakeJsonResponse("{}");
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
|
||||
var paths = new[] { MakeTempFile("a"), MakeTempFile("b") };
|
||||
try
|
||||
{
|
||||
var ex = await Assert.ThrowsAnyAsync<Exception>(() => chatClient.CreateVectorStoreAsync("kb", paths));
|
||||
|
||||
// The original upload-failure message must surface, not the cleanup-failure message.
|
||||
Assert.DoesNotContain("cleanup-failed", ex.Message ?? "", StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in paths)
|
||||
{
|
||||
File.Delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- DeleteVectorStoreAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_Mode1_CallsDeleteAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
|
||||
await chatClient.DeleteVectorStoreAsync("vs_abc");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_Mode2_CallsDeleteAsync()
|
||||
{
|
||||
var (chatClient, recorder) = CreateMode2(responseBody: FakeVectorStoreDeletedJson("vs_xyz"));
|
||||
await chatClient.DeleteVectorStoreAsync("vs_xyz");
|
||||
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_xyz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_NullId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var (chatClient, _) = CreateMode1();
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteVectorStoreAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVectorStoreAsync_HonorsCancellationAsync()
|
||||
{
|
||||
// Same approach as DeleteFileAsync_HonorsCancellationAsync — assert that the call
|
||||
// throws when the token is pre-cancelled, without asserting on the exact exception
|
||||
// surfaced by the SDK pipeline.
|
||||
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.DeleteVectorStoreAsync("vs_abc", cts.Token));
|
||||
}
|
||||
|
||||
// ----- Fixtures and helpers -----
|
||||
|
||||
private static HttpResponseMessage MakeJsonResponse(string json)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private static string FakeFileJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}";
|
||||
|
||||
private static string FakeFileDeletedJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"deleted\":true}}";
|
||||
|
||||
private static string FakeVectorStoreJson(string id, string name)
|
||||
=> FakeVectorStoreJsonWithStatus(id, name, status: "completed");
|
||||
|
||||
private static string FakeVectorStoreJsonWithStatus(string id, string name, string status)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"{status}\",\"last_active_at\":1700000000}}";
|
||||
|
||||
private static string FakeVectorStoreDeletedJson(string id)
|
||||
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store.deleted\",\"deleted\":true}}";
|
||||
|
||||
private sealed class RequestRecorder : HttpClientHandler
|
||||
{
|
||||
private readonly string _responseBody;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RequestRecorder(string? responseBody)
|
||||
{
|
||||
this._responseBody = responseBody ?? "{}";
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Requests.Add(new RecordedRequest
|
||||
{
|
||||
Method = request.Method.Method,
|
||||
PathAndQuery = request.RequestUri?.PathAndQuery ?? "",
|
||||
#if NET
|
||||
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false),
|
||||
#else
|
||||
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync().ConfigureAwait(false),
|
||||
#endif
|
||||
});
|
||||
return MakeJsonResponse(this._responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordedRequest
|
||||
{
|
||||
public string Method { get; set; } = "";
|
||||
public string PathAndQuery { get; set; } = "";
|
||||
public string Body { get; set; } = "";
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
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 OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, CS0618
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the public <c>ToPromptAgentAsync</c> extension methods on
|
||||
/// <see cref="ChatClientAgent"/> and <see cref="FoundryAgent"/>. Both entry points dispatch
|
||||
/// to the same internal converter, so each behavior is asserted through both surfaces.
|
||||
/// </summary>
|
||||
public sealed class FoundryPromptAgentConverterTests
|
||||
{
|
||||
// ----- Failure modes (assert through ChatClientAgent and FoundryAgent extensions) -----
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_NonFoundryChatClient_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
var agent = new ChatClientAgent(new NoOpChatClient());
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.ToPromptAgentAsync());
|
||||
Assert.Contains("FoundryChatClient", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_FoundryChatClientInMode3_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
var foundryAgent = new FoundryAgent(
|
||||
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
|
||||
credential: new FakeAuthenticationTokenProvider());
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => foundryAgent.ToPromptAgentAsync());
|
||||
Assert.Contains("Agent Endpoint mode (Mode 3)", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_MissingModelId_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
// Construct a FoundryChatClient via the Responses Agent mode (Mode 1) then wrap in a ChatClientAgent whose
|
||||
// ChatOptions has no ModelId — synthesis must throw.
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions { ChatOptions = new ChatOptions() });
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.ToPromptAgentAsync());
|
||||
Assert.Contains("model id", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_UnsupportedAITool_ThrowsInvalidOperationExceptionNamingTypeAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { new UnsupportedTool() },
|
||||
},
|
||||
});
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.ToPromptAgentAsync());
|
||||
Assert.Contains(nameof(UnsupportedTool), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_HonorsCancellationAsync()
|
||||
{
|
||||
// Cancellation should bubble up from the AgentReference fetch path. Construct a
|
||||
// FoundryAgent via AsAIAgent(AgentReference) and pass a pre-cancelled token.
|
||||
var (foundryAgent, _) = CreateMode2_PromptAgentOnly("agent-name");
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => foundryAgent.ToPromptAgentAsync(cts.Token));
|
||||
}
|
||||
|
||||
// ----- the Responses Agent mode (Mode 1) (RAPI) synthesis paths -----
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_RoundTripsModelInstructionsTemperatureTopPAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Instructions = "Be helpful.",
|
||||
Temperature = 0.5f,
|
||||
TopP = 0.9f,
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal("gpt-4o-mini", declarative.Model);
|
||||
Assert.Equal("Be helpful.", declarative.Instructions);
|
||||
Assert.Equal(0.5f, declarative.Temperature);
|
||||
Assert.Equal(0.9f, declarative.TopP);
|
||||
Assert.Empty(declarative.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_NoTools_ReturnsDefinitionWithEmptyToolsAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini" },
|
||||
});
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Empty(declarative.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_AIFunctionTool_ConvertsToFunctionToolAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var function = AIFunctionFactory.Create(() => "ok", "my_function", "A documented function.");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { function },
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
var fnTool = Assert.Single(declarative.Tools);
|
||||
var ft = Assert.IsType<FunctionTool>(fnTool);
|
||||
Assert.Equal("my_function", ft.FunctionName);
|
||||
Assert.Equal("A documented function.", ft.FunctionDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_FoundryAITool_UnwrapsUnderlyingResponseToolAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { FoundryAITool.CreateWebSearchTool() },
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
var tool = Assert.Single(declarative.Tools);
|
||||
// The unwrapped instance must be the concrete WebSearchTool from the OpenAI SDK.
|
||||
Assert.IsType<WebSearchTool>(tool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_MultipleToolsMixed_ConvertsAllInOrderAsync()
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini");
|
||||
var function = AIFunctionFactory.Create(() => "ok", "fn", "");
|
||||
var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = "gpt-4o-mini",
|
||||
Tools = new System.Collections.Generic.List<AITool> { function, FoundryAITool.CreateWebSearchTool() },
|
||||
},
|
||||
});
|
||||
|
||||
var def = await agent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal(2, declarative.Tools.Count);
|
||||
Assert.IsType<FunctionTool>(declarative.Tools[0]);
|
||||
Assert.IsType<WebSearchTool>(declarative.Tools[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode1_ResultIsDeclarativeAgentDefinitionAsync()
|
||||
{
|
||||
// FoundryAgent constructed via the projectEndpoint+model+instructions ctor (Responses Agent mode, the Responses Agent mode (Mode 1)).
|
||||
var foundryAgent = new FoundryAgent(
|
||||
projectEndpoint: new Uri("https://test.openai.azure.com/"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "You are helpful.");
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal("gpt-4o-mini", declarative.Model);
|
||||
Assert.Equal("You are helpful.", declarative.Instructions);
|
||||
}
|
||||
|
||||
// ----- the Prompt Agent mode (Mode 2) paths -----
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_AgentVersion_ReturnsCachedDefinitionAsync()
|
||||
{
|
||||
// Construct via ProjectsAgentVersion → the Definition reference must come back unchanged.
|
||||
var version = ModelReaderWriter.Read<ProjectsAgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
|
||||
var projectClient = CreateProjectClient();
|
||||
var foundryAgent = projectClient.AsAIAgent(version);
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
Assert.Same(version.Definition, def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_AgentRecord_ReturnsLatestVersionDefinitionAsync()
|
||||
{
|
||||
var record = ModelReaderWriter.Read<ProjectsAgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJson()))!;
|
||||
var projectClient = CreateProjectClient();
|
||||
var foundryAgent = projectClient.AsAIAgent(record);
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
Assert.Same(record.GetLatestVersion().Definition, def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_FetchesLatestVersionAsync()
|
||||
{
|
||||
// The handler returns a known agent JSON. The converter must hit GET /agents/{name}
|
||||
// and return that record's latest version definition.
|
||||
var fetched = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("/agents/agent-name"))
|
||||
{
|
||||
fetched = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name"), 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(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
Assert.True(fetched);
|
||||
Assert.NotNull(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_PinnedVersion_FetchesPinnedVersionAsync()
|
||||
{
|
||||
// Q-C regression: when AgentReference.Version is set, the converter must call
|
||||
// GET /agents/{name}/versions/{version} and return that pinned version's definition,
|
||||
// NOT GET /agents/{name} -> GetLatestVersion() which would silently substitute the
|
||||
// server's latest. We probe both paths from the same handler and assert exactly one was hit.
|
||||
var fetchedLatest = false;
|
||||
var fetchedPinned = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
// Pinned-version path: …/agents/{name}/versions/{version}
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("/agents/agent-name/versions/2", StringComparison.Ordinal))
|
||||
{
|
||||
fetchedPinned = true;
|
||||
var pinnedDef = new DeclarativeAgentDefinition("gpt-pinned") { Instructions = "Pinned-version instructions." };
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(agentName: "agent-name", agentDefinition: pinnedDef), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
// Latest-version path: …/agents/{name}
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.EndsWith("/agents/agent-name", StringComparison.Ordinal))
|
||||
{
|
||||
fetchedLatest = true;
|
||||
var latestDef = new DeclarativeAgentDefinition("gpt-latest") { Instructions = "Latest-version instructions." };
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name", agentDefinition: latestDef), 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(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name", "2"));
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
|
||||
Assert.True(fetchedPinned, "Pinned-version endpoint (.../agents/agent-name/versions/2) must be called when AgentReference.Version is set.");
|
||||
Assert.False(fetchedLatest, "Latest-version endpoint (.../agents/agent-name) must NOT be called when AgentReference.Version is set.");
|
||||
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
|
||||
Assert.Equal("gpt-pinned", declarative.Model);
|
||||
Assert.Equal("Pinned-version instructions.", declarative.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_UnpinnedVersionKeyword_FetchesLatestAsync()
|
||||
{
|
||||
// Q-C boundary: AgentReference.Version == "latest" must fall back to the GET /agents/{name}
|
||||
// path (the latest-version path), NOT GET /agents/{name}/versions/latest.
|
||||
var fetchedLatest = false;
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.EndsWith("/agents/agent-name", StringComparison.Ordinal))
|
||||
{
|
||||
fetchedLatest = true;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name"), 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(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name", "latest"));
|
||||
|
||||
var def = await foundryAgent.ToPromptAgentAsync();
|
||||
|
||||
Assert.True(fetchedLatest);
|
||||
Assert.NotNull(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_ServerReturnsError_PropagatesExceptionAsync()
|
||||
{
|
||||
using var handler = new HttpHandlerAssert(req =>
|
||||
new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{\"error\":{\"code\":\"NotFound\"}}", Encoding.UTF8, "application/json") });
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference("missing-agent"));
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => foundryAgent.ToPromptAgentAsync());
|
||||
}
|
||||
|
||||
// ----- Python-parity guard: both extensions produce equivalent definitions -----
|
||||
|
||||
[Fact]
|
||||
public async Task BothExtensions_ProduceEquivalentDefinitions_ForEquivalentInputsAsync()
|
||||
{
|
||||
// Build two agents that are semantically equivalent: one as a plain ChatClientAgent
|
||||
// via AsAIAgent(model, instructions), and one as a FoundryAgent via the projectEndpoint
|
||||
// ctor. Both flow through the same converter; assert key fields match.
|
||||
var projectClient = CreateProjectClient();
|
||||
ChatClientAgent ccaAgent = projectClient.AsAIAgent("gpt-4o-mini", "Be helpful.");
|
||||
var foundryAgent = new FoundryAgent(
|
||||
projectEndpoint: new Uri("https://test.openai.azure.com/"),
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Be helpful.");
|
||||
|
||||
var ccaDef = await ccaAgent.ToPromptAgentAsync();
|
||||
var faDef = await foundryAgent.ToPromptAgentAsync();
|
||||
|
||||
var a = Assert.IsType<DeclarativeAgentDefinition>(ccaDef);
|
||||
var b = Assert.IsType<DeclarativeAgentDefinition>(faDef);
|
||||
Assert.Equal(a.Model, b.Model);
|
||||
Assert.Equal(a.Instructions, b.Instructions);
|
||||
Assert.Equal(a.Tools.Count, b.Tools.Count);
|
||||
}
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
private static AIProjectClient CreateProjectClient()
|
||||
=> new(
|
||||
new Uri("https://test.openai.azure.com/"),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(new HttpClient()) });
|
||||
|
||||
private static (FoundryAgent FoundryAgent, AIProjectClient ProjectClient) CreateMode2_PromptAgentOnly(string agentName)
|
||||
{
|
||||
var projectClient = CreateProjectClient();
|
||||
var foundryAgent = projectClient.AsAIAgent(new AgentReference(agentName));
|
||||
return (foundryAgent, projectClient);
|
||||
}
|
||||
|
||||
private sealed class NoOpChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(System.Collections.Generic.IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ChatResponse());
|
||||
|
||||
public System.Collections.Generic.IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(System.Collections.Generic.IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> EmptyAsyncEnumerableAsync();
|
||||
|
||||
private static async System.Collections.Generic.IAsyncEnumerable<ChatResponseUpdate> EmptyAsyncEnumerableAsync()
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
yield break;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class UnsupportedTool : AITool
|
||||
{
|
||||
public override string Name => "unsupported";
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot verification (kept in tree to detect regressions) that MEAI 10.5.1 stamps its own
|
||||
/// <c>MEAI/{version}</c> User-Agent segment automatically when an <see cref="ResponsesClient"/>
|
||||
/// is wrapped via <c>AsIChatClient()</c>. If this test starts failing, the FoundryChatClient
|
||||
/// implementation must re-register the MEAI policy explicitly via OpenAIRequestPolicies because
|
||||
/// the local Foundry copy was deleted under the assumption that MEAI provides it built-in.
|
||||
/// </summary>
|
||||
public sealed class MeaiAutoUserAgentVerificationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeaiOpenAIResponsesClient_StampsMeaiSegmentAutomatically_WithoutLocalPolicyAsync()
|
||||
{
|
||||
// Arrange: bare OpenAI ResponseClient over a fake HTTP transport, wrapped via MEAI's
|
||||
// AsIChatClient() with no custom OpenAIRequestPolicies registration. If MEAI auto-stamps
|
||||
// its own MEAI/{version} segment, it will appear here.
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var options = new OpenAIClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
Endpoint = new Uri("https://example.test/v1"),
|
||||
};
|
||||
|
||||
var responseClient = new ResponsesClient(new ApiKeyCredential("test-key"), options);
|
||||
var chatClient = responseClient.AsIChatClient("gpt-4o-mini");
|
||||
|
||||
// Act: send a request through MEAI's chat client. The fake transport will throw on
|
||||
// response parsing, but we only care about the outbound headers, which are captured
|
||||
// before the response is parsed.
|
||||
try
|
||||
{
|
||||
await chatClient.GetResponseAsync("hi", cancellationToken: CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected: the fake response body is not parseable as a Responses API payload.
|
||||
}
|
||||
|
||||
// Assert: at least one outbound request reached the transport, and its User-Agent
|
||||
// contains either "MEAI/" (auto-stamped by MEAI) or no MEAI segment (verification
|
||||
// signal — see test summary).
|
||||
Assert.True(handler.Count > 0, "Expected at least one outbound request from MEAI wrapper.");
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
// INTENT: assert that MEAI auto-stamps. If the assertion fails, see the FoundryChatClient
|
||||
// implementation note about needing to register the MEAI policy explicitly.
|
||||
Assert.Contains("MEAI/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-call <c>MeaiUserAgentPolicy</c> exposed via
|
||||
/// <see cref="RequestOptionsExtensions.UserAgentPolicy"/>. The policy is reachable through the
|
||||
/// public <see cref="FoundryAgent"/> constructors (which add it to the internally-built
|
||||
/// <see cref="Azure.AI.Projects.AIProjectClient"/>'s pipeline), so its behavior is part of the
|
||||
/// public API surface.
|
||||
/// </summary>
|
||||
public sealed class RequestOptionsExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, handler.Count);
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("MEAI/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere
|
||||
// (by the polyfill UserAgentResponsesClient → HostedAgentUserAgentPolicy).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserAgentPolicy_ExposesSingletonInstance()
|
||||
{
|
||||
// Two reads of the static property must return the same instance — the policy is stateless and shared.
|
||||
var first = RequestOptionsExtensions.UserAgentPolicy;
|
||||
var second = RequestOptionsExtensions.UserAgentPolicy;
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
|
||||
{
|
||||
// The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
|
||||
// If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version,
|
||||
// which is a measurable telemetry regression.
|
||||
var attr = typeof(RequestOptionsExtensions).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.FileAccessStore);
|
||||
Assert.Null(options.AgentModeProviderOptions);
|
||||
Assert.Null(options.AgentSkillsSource);
|
||||
Assert.Null(options.BackgroundAgents);
|
||||
Assert.Null(options.BackgroundAgentsProviderOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -52,6 +54,8 @@ public class HarnessAgentOptionsTests
|
||||
var fileAccessStore = new Mock<AgentFileStore>().Object;
|
||||
var agentModeOptions = new AgentModeProviderOptions();
|
||||
var skillsSource = new Mock<AgentSkillsSource>().Object;
|
||||
var backgroundAgents = new AIAgent[] { new Mock<AIAgent>().Object };
|
||||
var backgroundAgentsOptions = new BackgroundAgentsProviderOptions();
|
||||
|
||||
// Act
|
||||
var options = new HarnessAgentOptions
|
||||
@@ -77,6 +81,8 @@ public class HarnessAgentOptionsTests
|
||||
AgentSkillsSource = skillsSource,
|
||||
DisableOpenTelemetry = true,
|
||||
OpenTelemetrySourceName = "custom-source",
|
||||
BackgroundAgents = backgroundAgents,
|
||||
BackgroundAgentsProviderOptions = backgroundAgentsOptions,
|
||||
};
|
||||
|
||||
// Assert
|
||||
@@ -103,5 +109,7 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Same(skillsSource, options.AgentSkillsSource);
|
||||
Assert.True(options.DisableOpenTelemetry);
|
||||
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
|
||||
Assert.Same(backgroundAgents, options.BackgroundAgents);
|
||||
Assert.Same(backgroundAgentsOptions, options.BackgroundAgentsProviderOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,4 +1197,154 @@ public class HarnessAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: BackgroundAgentsProvider
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProvider is included when BackgroundAgents are specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BackgroundAgentsProvider_IncludedWhenAgentsSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var bgAgentMock = new Mock<AIAgent>();
|
||||
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = [bgAgentMock.Object];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent?.AIContextProviders);
|
||||
Assert.Contains(innerAgent!.AIContextProviders!, p => p is BackgroundAgentsProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BackgroundAgentsProvider_ExcludedWhenAgentsNull()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
if (innerAgent!.AIContextProviders != null)
|
||||
{
|
||||
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is an empty collection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BackgroundAgentsProvider_ExcludedWhenAgentsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = Array.Empty<AIAgent>();
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
if (innerAgent!.AIContextProviders != null)
|
||||
{
|
||||
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that BackgroundAgentsProviderOptions is passed through when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BackgroundAgentsProvider_UsesProvidedOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var bgAgentMock = new Mock<AIAgent>();
|
||||
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
|
||||
bgAgentMock.Setup(a => a.Description).Returns("A test background agent");
|
||||
var providerOptions = new BackgroundAgentsProviderOptions
|
||||
{
|
||||
Instructions = "Custom instructions with {background_agents} list.",
|
||||
};
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = [bgAgentMock.Object];
|
||||
options.BackgroundAgentsProviderOptions = providerOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
#pragma warning disable MAAI001
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
new Mock<AIAgent>().Object,
|
||||
new Mock<AgentSession>().Object,
|
||||
new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
AIContext result = await bgProvider.InvokingAsync(invokingContext);
|
||||
|
||||
// Assert — custom instructions template is used and agent info is included
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("Custom instructions with", result.Instructions);
|
||||
Assert.Contains("TestBackgroundAgent", result.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that multiple background agents are all passed to the provider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BackgroundAgentsProvider_IncludesMultipleAgentsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var agent1Mock = new Mock<AIAgent>();
|
||||
agent1Mock.Setup(a => a.Name).Returns("Agent1");
|
||||
agent1Mock.Setup(a => a.Description).Returns("First agent");
|
||||
var agent2Mock = new Mock<AIAgent>();
|
||||
agent2Mock.Setup(a => a.Name).Returns("Agent2");
|
||||
agent2Mock.Setup(a => a.Description).Returns("Second agent");
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.BackgroundAgents = [agent1Mock.Object, agent2Mock.Object];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
#pragma warning disable MAAI001
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
new Mock<AIAgent>().Object,
|
||||
new Mock<AgentSession>().Object,
|
||||
new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
AIContext result = await bgProvider.InvokingAsync(invokingContext);
|
||||
|
||||
// Assert — both agents appear in the provider's instructions
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("Agent1", result.Instructions);
|
||||
Assert.Contains("First agent", result.Instructions);
|
||||
Assert.Contains("Agent2", result.Instructions);
|
||||
Assert.Contains("Second agent", result.Instructions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+1
-2
@@ -44,7 +44,6 @@ GEMINI_MODEL=""
|
||||
# Ollama
|
||||
OLLAMA_ENDPOINT=""
|
||||
OLLAMA_MODEL=""
|
||||
# Observability
|
||||
ENABLE_INSTRUMENTATION=true
|
||||
# Observability (instrumentation is enabled by default; set "ENABLE_INSTRUMENTATION" to "false" to opt out)
|
||||
ENABLE_SENSITIVE_DATA=true
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ def equal(arg1: str, arg2: str) -> bool:
|
||||
from agent_framework import Agent, Message, tool
|
||||
|
||||
# Components
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
|
||||
# Connectors (lazy-loaded)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
@@ -93,3 +93,4 @@ python/
|
||||
|
||||
### Experimental
|
||||
- [lab](packages/lab/AGENTS.md) - Experimental features
|
||||
- [monty](packages/monty/AGENTS.md) - Monty-backed CodeAct integrations (alpha)
|
||||
|
||||
@@ -186,7 +186,7 @@ The package follows a flat import structure:
|
||||
|
||||
- **Components**: Import from `agent_framework.<component>`
|
||||
```python
|
||||
from agent_framework.observability import enable_instrumentation, configure_otel_providers
|
||||
from agent_framework.observability import enable_sensitive_telemetry, configure_otel_providers
|
||||
```
|
||||
|
||||
- **Connectors**: Import from `agent_framework.<vendor/platform>`
|
||||
|
||||
@@ -37,6 +37,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
| `agent-framework-monty` | `python/packages/monty` | `alpha` |
|
||||
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
|
||||
| `agent-framework-openai` | `python/packages/openai` | `released` |
|
||||
| `agent-framework-orchestrations` | `python/packages/orchestrations` | `beta` |
|
||||
|
||||
@@ -129,6 +129,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self._timeout_config = self._create_timeout_config(timeout)
|
||||
if client is not None:
|
||||
self.client = client
|
||||
self._non_streaming_client: Client | None = None
|
||||
self._close_http_client = True
|
||||
return
|
||||
if agent_card is None:
|
||||
@@ -144,17 +145,30 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self._http_client = http_client # Store for cleanup
|
||||
self._close_http_client = True
|
||||
|
||||
# Create A2A client using factory
|
||||
config = ClientConfig(
|
||||
interceptors = [auth_interceptor] if auth_interceptor is not None else None
|
||||
|
||||
# Create streaming client (SSE transport for stream=True)
|
||||
streaming_config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
streaming=True,
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
)
|
||||
factory = ClientFactory(config)
|
||||
interceptors = [auth_interceptor] if auth_interceptor is not None else None
|
||||
# Create non-streaming client (single request/response for stream=False)
|
||||
non_streaming_config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
streaming=False,
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
)
|
||||
streaming_factory = ClientFactory(streaming_config)
|
||||
non_streaming_factory = ClientFactory(non_streaming_config)
|
||||
|
||||
# Attempt transport negotiation with the provided agent card
|
||||
try:
|
||||
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
|
||||
self.client = streaming_factory.create(agent_card, interceptors=interceptors) # type: ignore
|
||||
self._non_streaming_client = non_streaming_factory.create(
|
||||
agent_card,
|
||||
interceptors=interceptors, # type: ignore
|
||||
)
|
||||
except Exception as transport_error:
|
||||
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
|
||||
fallback_url = agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
|
||||
@@ -166,7 +180,11 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
) from transport_error
|
||||
fallback_card = minimal_agent_card(fallback_url, ["JSONRPC"])
|
||||
try:
|
||||
self.client = factory.create(fallback_card, interceptors=interceptors) # type: ignore
|
||||
self.client = streaming_factory.create(fallback_card, interceptors=interceptors) # type: ignore
|
||||
self._non_streaming_client = non_streaming_factory.create(
|
||||
fallback_card,
|
||||
interceptors=interceptors, # type: ignore
|
||||
)
|
||||
except Exception as fallback_error:
|
||||
raise RuntimeError(
|
||||
f"A2A transport negotiation failed. "
|
||||
@@ -282,6 +300,13 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
del function_invocation_kwargs, client_kwargs, kwargs
|
||||
normalized_messages = normalize_messages(messages)
|
||||
|
||||
# Use non-streaming transport for non-streaming calls when available.
|
||||
# This sends a single HTTP request/response instead of opening an SSE
|
||||
# connection, matching the protocol's intent for synchronous operations.
|
||||
active_client = (
|
||||
self._non_streaming_client if (not stream and self._non_streaming_client is not None) else self.client
|
||||
)
|
||||
|
||||
if continuation_token is not None:
|
||||
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.subscribe(
|
||||
SubscribeToTaskRequest(id=continuation_token["task_id"])
|
||||
@@ -293,7 +318,11 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
normalized_messages[-1],
|
||||
context_id=session.service_session_id if session else None,
|
||||
)
|
||||
a2a_stream = self.client.send_message(SendMessageRequest(message=a2a_message))
|
||||
request = SendMessageRequest(message=a2a_message)
|
||||
if background and not stream:
|
||||
# return_immediately only applies to non-streaming (message/send)
|
||||
request.configuration.return_immediately = True
|
||||
a2a_stream = active_client.send_message(request)
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
|
||||
@@ -44,6 +44,7 @@ class MockA2AClient:
|
||||
self.subscribe_responses: list[StreamResponse] = []
|
||||
self.get_task_response: Task | None = None
|
||||
self.last_message: Any = None
|
||||
self.last_request: Any = None
|
||||
|
||||
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
|
||||
"""Add a mock Message response."""
|
||||
@@ -91,6 +92,7 @@ class MockA2AClient:
|
||||
|
||||
async def send_message(self, request: Any) -> AsyncIterator[StreamResponse]:
|
||||
"""Mock send_message method that yields responses."""
|
||||
self.last_request = request
|
||||
self.last_message = getattr(request, "message", request)
|
||||
self.call_count += 1
|
||||
|
||||
@@ -745,6 +747,96 @@ async def test_working_task_no_token_without_background(a2a_agent: A2AAgent, moc
|
||||
assert response.continuation_token is None
|
||||
|
||||
|
||||
async def test_background_sets_return_immediately_on_request(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that background=True sets return_immediately=True on SendMessageRequest configuration."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-bg", state=TaskState.TASK_STATE_WORKING)
|
||||
|
||||
await a2a_agent.run("Background task", background=True)
|
||||
|
||||
assert mock_a2a_client.last_request.configuration.return_immediately is True
|
||||
|
||||
|
||||
async def test_foreground_does_not_set_return_immediately(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that background=False (default) does not set configuration on SendMessageRequest."""
|
||||
mock_a2a_client.add_task_response("task-fg2", [{"id": "art-1", "content": "Done"}])
|
||||
|
||||
await a2a_agent.run("Foreground task")
|
||||
|
||||
assert mock_a2a_client.last_request.HasField("configuration") is False
|
||||
|
||||
|
||||
async def test_streaming_background_does_not_set_return_immediately(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that background=True with stream=True does not set return_immediately.
|
||||
|
||||
Per A2A spec, return_immediately only applies to non-streaming (message/send).
|
||||
"""
|
||||
mock_a2a_client.add_task_response("task-sb", [{"id": "art-1", "content": "Streaming bg"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Stream background", stream=True, background=True):
|
||||
updates.append(update)
|
||||
|
||||
assert mock_a2a_client.last_request.HasField("configuration") is False
|
||||
|
||||
|
||||
async def test_non_streaming_run_uses_non_streaming_client() -> None:
|
||||
"""Test that stream=False uses the non-streaming client when available."""
|
||||
streaming_client = MockA2AClient()
|
||||
non_streaming_client = MockA2AClient()
|
||||
non_streaming_client.add_task_response("task-ns", [{"id": "art-1", "content": "Non-streaming result"}])
|
||||
|
||||
agent = A2AAgent(name="Test Agent", id="test-ns", client=streaming_client, http_client=None)
|
||||
agent._non_streaming_client = non_streaming_client # type: ignore[assignment]
|
||||
|
||||
response = await agent.run("Hello")
|
||||
|
||||
# Non-streaming client should have been called
|
||||
assert non_streaming_client.call_count == 1
|
||||
assert streaming_client.call_count == 0
|
||||
assert response.messages[0].text == "Non-streaming result"
|
||||
assert non_streaming_client.last_request.HasField("configuration") is False
|
||||
|
||||
|
||||
async def test_streaming_run_uses_streaming_client() -> None:
|
||||
"""Test that stream=True always uses the streaming client."""
|
||||
streaming_client = MockA2AClient()
|
||||
non_streaming_client = MockA2AClient()
|
||||
streaming_client.add_task_response("task-s", [{"id": "art-1", "content": "Streaming result"}])
|
||||
|
||||
agent = A2AAgent(name="Test Agent", id="test-s", client=streaming_client, http_client=None)
|
||||
agent._non_streaming_client = non_streaming_client # type: ignore[assignment]
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
# Streaming client should have been called
|
||||
assert streaming_client.call_count == 1
|
||||
assert non_streaming_client.call_count == 0
|
||||
assert updates[0].contents[0].text == "Streaming result"
|
||||
|
||||
|
||||
async def test_non_streaming_client_fallback_when_not_available(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that stream=False falls back to streaming client when non-streaming client is unavailable."""
|
||||
mock_a2a_client.add_task_response("task-fb", [{"id": "art-1", "content": "Fallback result"}])
|
||||
|
||||
# a2a_agent is created with client= param so _non_streaming_client is None
|
||||
assert a2a_agent._non_streaming_client is None
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
assert mock_a2a_client.call_count == 1
|
||||
assert response.messages[0].text == "Fallback result"
|
||||
|
||||
|
||||
async def test_completed_task_has_no_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a completed task does not set a continuation token."""
|
||||
mock_a2a_client.add_task_response("task-done", [{"id": "art-1", "content": "Result"}])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.0.0rc2"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
|
||||
from agent_framework import (
|
||||
@@ -31,11 +30,6 @@ from chatkit.types import (
|
||||
WorkflowItem,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import assert_never # type:ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import assert_never # type:ignore # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -532,7 +526,10 @@ class ThreadItemConverter:
|
||||
# TODO(evmattso): Implement structured input handling in a future PR
|
||||
return []
|
||||
case _:
|
||||
assert_never(item)
|
||||
# Unknown ThreadItem variant (e.g. types added in newer chatkit versions).
|
||||
# Skip rather than fail so we remain forward-compatible with chatkit upgrades.
|
||||
logger.debug("Skipping unsupported ThreadItem of type %s", type(item).__name__)
|
||||
return []
|
||||
|
||||
async def to_agent_input(
|
||||
self,
|
||||
|
||||
@@ -49,6 +49,8 @@ class ExperimentalFeature(str, Enum):
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
FIDES = "FIDES"
|
||||
FOUNDRY_TOOLS = "FOUNDRY_TOOLS"
|
||||
FOUNDRY_PREVIEW_TOOLS = "FOUNDRY_PREVIEW_TOOLS"
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
HARNESS = "HARNESS"
|
||||
SKILLS = "SKILLS"
|
||||
|
||||
@@ -255,7 +255,7 @@ class MCPTool:
|
||||
self._exit_stack = AsyncExitStack()
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
self._lifecycle_request_lock = asyncio.Lock()
|
||||
self._lifecycle_queue: asyncio.Queue[tuple[str, bool, asyncio.Future[None]]] | None = None
|
||||
self._lifecycle_queue: asyncio.Queue[tuple[str, bool, bool, asyncio.Future[None]]] | None = None
|
||||
self._lifecycle_owner_task: asyncio.Task[None] | None = None
|
||||
self.session = session
|
||||
self.request_timeout = request_timeout
|
||||
@@ -265,6 +265,11 @@ class MCPTool:
|
||||
self.is_connected: bool = False
|
||||
self._tools_loaded: bool = False
|
||||
self._prompts_loaded: bool = False
|
||||
self._server_capabilities: types.ServerCapabilities | None = None
|
||||
self._supports_tools: bool = True
|
||||
self._supports_prompts: bool = True
|
||||
self._supports_logging: bool | None = None
|
||||
self._ping_available: bool = True
|
||||
self._pending_reload_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -566,11 +571,11 @@ class MCPTool:
|
||||
stop_error: BaseException | None = None
|
||||
try:
|
||||
while True:
|
||||
action, reset, future = await queue.get()
|
||||
action, reset, load_configured, future = await queue.get()
|
||||
|
||||
try:
|
||||
if action == "connect":
|
||||
await self._connect_on_owner(reset=reset)
|
||||
await self._connect_on_owner(reset=reset, load_configured=load_configured)
|
||||
elif action == "close":
|
||||
await self._close_on_owner()
|
||||
else:
|
||||
@@ -595,7 +600,7 @@ class MCPTool:
|
||||
finally:
|
||||
while True:
|
||||
try:
|
||||
_, _, future = queue.get_nowait()
|
||||
_, _, _, future = queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if not future.done():
|
||||
@@ -608,12 +613,18 @@ class MCPTool:
|
||||
owner_task = self._lifecycle_owner_task
|
||||
return owner_task is not None and asyncio.current_task() is owner_task
|
||||
|
||||
async def _run_on_lifecycle_owner(self, action: str, *, reset: bool = False) -> None:
|
||||
async def _run_on_lifecycle_owner(
|
||||
self,
|
||||
action: str,
|
||||
*,
|
||||
reset: bool = False,
|
||||
load_configured: bool = True,
|
||||
) -> None:
|
||||
await self._ensure_lifecycle_owner()
|
||||
|
||||
if self._is_lifecycle_owner_task():
|
||||
if action == "connect":
|
||||
await self._connect_on_owner(reset=reset)
|
||||
await self._connect_on_owner(reset=reset, load_configured=load_configured)
|
||||
elif action == "close":
|
||||
await self._close_on_owner()
|
||||
else:
|
||||
@@ -625,7 +636,7 @@ class MCPTool:
|
||||
raise RuntimeError("MCP lifecycle owner is not available.")
|
||||
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
await queue.put((action, reset, future))
|
||||
await queue.put((action, reset, load_configured, future))
|
||||
await future
|
||||
|
||||
async def _safe_close_exit_stack(self) -> None:
|
||||
@@ -656,6 +667,32 @@ class MCPTool:
|
||||
await self._safe_close_exit_stack()
|
||||
return _should_propagate_cancelled_error(ex)
|
||||
|
||||
def _reset_session_state(self) -> None:
|
||||
self._server_capabilities = None
|
||||
self._supports_tools = True
|
||||
self._supports_prompts = True
|
||||
self._supports_logging = None
|
||||
self._ping_available = True
|
||||
|
||||
def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None:
|
||||
self._server_capabilities = capabilities
|
||||
if capabilities is None:
|
||||
self._supports_tools = False
|
||||
self._supports_prompts = False
|
||||
self._supports_logging = False
|
||||
return
|
||||
|
||||
self._supports_tools = getattr(capabilities, "tools", None) is not None
|
||||
self._supports_prompts = getattr(capabilities, "prompts", None) is not None
|
||||
self._supports_logging = getattr(capabilities, "logging", None) is not None
|
||||
|
||||
async def _reconnect_without_loading(self) -> None:
|
||||
if self._is_lifecycle_owner_task():
|
||||
await self._connect_on_owner(reset=True, load_configured=False)
|
||||
return
|
||||
|
||||
await self._run_on_lifecycle_owner("connect", reset=True, load_configured=False)
|
||||
|
||||
async def connect(self, *, reset: bool = False) -> None:
|
||||
if self._is_lifecycle_owner_task():
|
||||
await self._connect_on_owner(reset=reset)
|
||||
@@ -664,7 +701,7 @@ class MCPTool:
|
||||
async with self._lifecycle_request_lock:
|
||||
await self._run_on_lifecycle_owner("connect", reset=reset)
|
||||
|
||||
async def _connect_on_owner(self, *, reset: bool = False) -> None:
|
||||
async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool = True) -> None:
|
||||
"""Connect to the MCP server.
|
||||
|
||||
Establishes a connection to the MCP server, initializes the session,
|
||||
@@ -672,6 +709,7 @@ class MCPTool:
|
||||
|
||||
Keyword Args:
|
||||
reset: If True, forces a reconnection even if already connected.
|
||||
load_configured: If True, loads tools and prompts according to the constructor flags.
|
||||
|
||||
Raises:
|
||||
ToolException: If connection or session initialization fails.
|
||||
@@ -680,6 +718,7 @@ class MCPTool:
|
||||
await self._safe_close_exit_stack()
|
||||
self.session = None
|
||||
self.is_connected = False
|
||||
self._reset_session_state()
|
||||
self._exit_stack = AsyncExitStack()
|
||||
if not self.session:
|
||||
try:
|
||||
@@ -741,7 +780,8 @@ class MCPTool:
|
||||
inner_exception=ex if isinstance(ex, Exception) else None,
|
||||
) from ex
|
||||
try:
|
||||
await session.initialize()
|
||||
initialize_result = await session.initialize()
|
||||
self._set_server_capabilities(getattr(initialize_result, "capabilities", None))
|
||||
except (Exception, asyncio.CancelledError) as ex:
|
||||
if await self._close_and_check_cancelled(ex):
|
||||
raise
|
||||
@@ -759,17 +799,22 @@ class MCPTool:
|
||||
self.session = session
|
||||
elif self.session._request_id == 0: # type: ignore[attr-defined]
|
||||
# If the session is not initialized, we need to reinitialize it
|
||||
await self.session.initialize()
|
||||
initialize_result = await self.session.initialize()
|
||||
self._set_server_capabilities(getattr(initialize_result, "capabilities", None))
|
||||
elif self._server_capabilities is None:
|
||||
self._set_server_capabilities(getattr(self.session, "_server_capabilities", None))
|
||||
logger.debug("Connected to MCP server: %s", self.session)
|
||||
self.is_connected = True
|
||||
if self.load_tools_flag:
|
||||
await self.load_tools()
|
||||
if load_configured and self.load_tools_flag:
|
||||
if self._supports_tools:
|
||||
await self.load_tools()
|
||||
self._tools_loaded = True
|
||||
if self.load_prompts_flag:
|
||||
await self.load_prompts()
|
||||
if load_configured and self.load_prompts_flag:
|
||||
if self._supports_prompts:
|
||||
await self.load_prompts()
|
||||
self._prompts_loaded = True
|
||||
|
||||
if logger.level != logging.NOTSET:
|
||||
if logger.level != logging.NOTSET and self._supports_logging is not False:
|
||||
try:
|
||||
level_name = cast(
|
||||
Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
|
||||
@@ -973,17 +1018,49 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import types
|
||||
|
||||
if not self._supports_prompts:
|
||||
logger.debug("Skipping MCP prompt loading because the server did not advertise prompts support.")
|
||||
return
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
|
||||
params: types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
prompt_list: types.ListPromptsResult | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
if not self._supports_prompts:
|
||||
logger.debug(
|
||||
"Skipping MCP prompt loading because the server did not advertise prompts support."
|
||||
)
|
||||
return
|
||||
prompt_list = await self.session.list_prompts(params=params) # type: ignore[union-attr]
|
||||
break
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
logger.info("MCP connection closed unexpectedly while loading prompts. Reconnecting...")
|
||||
try:
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as reconn_ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
continue
|
||||
logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex)
|
||||
raise ToolExecutionException(
|
||||
"Failed to load prompts - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
|
||||
prompt_list = await self.session.list_prompts(params=params) # type: ignore[union-attr]
|
||||
if prompt_list is None:
|
||||
raise ToolExecutionException("Failed to load prompts.")
|
||||
|
||||
for prompt in prompt_list.prompts:
|
||||
normalized_name = _normalize_mcp_name(prompt.name)
|
||||
@@ -1010,7 +1087,7 @@ class MCPTool:
|
||||
existing_names.add(local_name)
|
||||
|
||||
# Check if there are more pages
|
||||
if not prompt_list or not prompt_list.nextCursor:
|
||||
if not prompt_list.nextCursor:
|
||||
break
|
||||
params = types.PaginatedRequestParams(cursor=prompt_list.nextCursor)
|
||||
|
||||
@@ -1023,18 +1100,48 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import types
|
||||
|
||||
if not self._supports_tools:
|
||||
logger.debug("Skipping MCP tool loading because the server did not advertise tools support.")
|
||||
return
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
self._tool_call_meta_by_name.clear()
|
||||
|
||||
params: types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
tool_list: types.ListToolsResult | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
if not self._supports_tools:
|
||||
logger.debug("Skipping MCP tool loading because the server did not advertise tools support.")
|
||||
return
|
||||
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
|
||||
break
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
logger.info("MCP connection closed unexpectedly while loading tools. Reconnecting...")
|
||||
try:
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as reconn_ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
continue
|
||||
logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex)
|
||||
raise ToolExecutionException(
|
||||
"Failed to load tools - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
|
||||
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
|
||||
if tool_list is None:
|
||||
raise ToolExecutionException("Failed to load tools.")
|
||||
|
||||
for tool in tool_list.tools:
|
||||
if tool.meta is not None:
|
||||
@@ -1083,7 +1190,7 @@ class MCPTool:
|
||||
existing_names.add(local_name)
|
||||
|
||||
# Check if there are more pages
|
||||
if not tool_list or not tool_list.nextCursor:
|
||||
if not tool_list.nextCursor:
|
||||
break
|
||||
params = types.PaginatedRequestParams(cursor=tool_list.nextCursor)
|
||||
|
||||
@@ -1100,6 +1207,7 @@ class MCPTool:
|
||||
self._exit_stack = AsyncExitStack()
|
||||
self.session = None
|
||||
self.is_connected = False
|
||||
self._reset_session_state()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Disconnect from the MCP server.
|
||||
@@ -1131,12 +1239,30 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If reconnection fails.
|
||||
"""
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
if not self._ping_available:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.session.send_ping() # type: ignore[union-attr]
|
||||
except McpError as mcp_exc:
|
||||
if mcp_exc.error.code == -32601:
|
||||
self._ping_available = False
|
||||
logger.debug("Skipping future MCP pings because the server does not support ping.")
|
||||
return
|
||||
logger.info("MCP connection invalid or closed. Reconnecting...")
|
||||
try:
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to establish MCP connection.",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
except Exception:
|
||||
logger.info("MCP connection invalid or closed. Reconnecting...")
|
||||
try:
|
||||
await self.connect(reset=True)
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to establish MCP connection.",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Commonly used exports:
|
||||
- enable_instrumentation
|
||||
- disable_instrumentation
|
||||
- enable_sensitive_telemetry
|
||||
- configure_otel_providers
|
||||
- AgentTelemetryLayer
|
||||
- ChatTelemetryLayer
|
||||
@@ -80,7 +82,9 @@ __all__ = [
|
||||
"configure_otel_providers",
|
||||
"create_metric_views",
|
||||
"create_resource",
|
||||
"disable_instrumentation",
|
||||
"enable_instrumentation",
|
||||
"enable_sensitive_telemetry",
|
||||
"get_meter",
|
||||
"get_tracer",
|
||||
]
|
||||
@@ -643,8 +647,8 @@ class ObservabilitySettings:
|
||||
Sensitive events should only be enabled on test and development environments.
|
||||
|
||||
Keyword Args:
|
||||
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is False.
|
||||
Can be set via environment variable ENABLE_INSTRUMENTATION.
|
||||
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is True.
|
||||
Can be disabled by setting environment variable ENABLE_INSTRUMENTATION=false.
|
||||
enable_sensitive_data: Enable OpenTelemetry sensitive events. Default is False.
|
||||
Can be set via environment variable ENABLE_SENSITIVE_DATA.
|
||||
enable_console_exporters: Enable console exporters for traces, logs, and metrics.
|
||||
@@ -659,12 +663,12 @@ class ObservabilitySettings:
|
||||
from agent_framework import ObservabilitySettings
|
||||
|
||||
# Using environment variables
|
||||
# Set ENABLE_INSTRUMENTATION=true
|
||||
# Instrumentation is enabled by default; set ENABLE_INSTRUMENTATION=false to disable.
|
||||
# Set ENABLE_CONSOLE_EXPORTERS=true
|
||||
settings = ObservabilitySettings()
|
||||
|
||||
# Or passing parameters directly
|
||||
settings = ObservabilitySettings(enable_instrumentation=True, enable_console_exporters=True)
|
||||
settings = ObservabilitySettings(enable_console_exporters=True)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
@@ -677,14 +681,74 @@ class ObservabilitySettings:
|
||||
env_file_encoding=env_file_encoding,
|
||||
**kwargs,
|
||||
)
|
||||
self.enable_instrumentation: bool = data.get("enable_instrumentation") or False
|
||||
self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
|
||||
# Sticky-disable flag, set by `disable_instrumentation()`. When True, this
|
||||
# singleton refuses to be re-enabled by any subsequent assignment to the
|
||||
# `enable_instrumentation` / `enable_sensitive_data` properties (including
|
||||
# direct third-party writes). It can only be cleared by an explicit
|
||||
# `enable_instrumentation(force=True)` / `enable_sensitive_telemetry(force=True)`
|
||||
# call, which is the user re-stating their intent.
|
||||
self._user_disabled: bool = False
|
||||
# `enable_instrumentation` is defaulted to True if not set
|
||||
instrumentation_value = data.get("enable_instrumentation")
|
||||
self._enable_instrumentation: bool = True if instrumentation_value is None else instrumentation_value
|
||||
self._enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
|
||||
if self._enable_sensitive_data and not self._enable_instrumentation:
|
||||
logger.warning(
|
||||
"Sensitive data capture is enabled but instrumentation is disabled. "
|
||||
"Sensitive data will not be captured. Please enable instrumentation to capture sensitive data."
|
||||
)
|
||||
|
||||
self.enable_console_exporters: bool = data.get("enable_console_exporters") or False
|
||||
self.vs_code_extension_port: int | None = data.get("vs_code_extension_port")
|
||||
self.env_file_path = env_file_path
|
||||
self.env_file_encoding = env_file_encoding
|
||||
self._executed_setup = False
|
||||
|
||||
@property
|
||||
def enable_instrumentation(self) -> bool:
|
||||
"""Whether instrumentation is enabled.
|
||||
|
||||
Always returns False once ``disable_instrumentation()`` has been called,
|
||||
regardless of the stored value, until ``enable_instrumentation(force=True)``
|
||||
clears the sticky disable.
|
||||
"""
|
||||
if self._user_disabled:
|
||||
return False
|
||||
return self._enable_instrumentation
|
||||
|
||||
@enable_instrumentation.setter
|
||||
def enable_instrumentation(self, value: bool) -> None:
|
||||
if self._user_disabled and value:
|
||||
# Defense in depth: a third-party (or internal) write of True is
|
||||
# silently dropped while the user-disabled flag is set, so the
|
||||
# sticky disable cannot be circumvented by direct attribute writes.
|
||||
logger.debug(
|
||||
"Ignoring enable_instrumentation=True assignment: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Call enable_instrumentation(force=True) to clear the disable."
|
||||
)
|
||||
return
|
||||
self._enable_instrumentation = value
|
||||
|
||||
@property
|
||||
def enable_sensitive_data(self) -> bool:
|
||||
"""Whether sensitive-data capture is enabled.
|
||||
|
||||
Always returns False once ``disable_instrumentation()`` has been called.
|
||||
"""
|
||||
if self._user_disabled:
|
||||
return False
|
||||
return self._enable_sensitive_data
|
||||
|
||||
@enable_sensitive_data.setter
|
||||
def enable_sensitive_data(self, value: bool) -> None:
|
||||
if self._user_disabled and value:
|
||||
logger.debug(
|
||||
"Ignoring enable_sensitive_data=True assignment: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Call enable_sensitive_telemetry(force=True) to clear the disable."
|
||||
)
|
||||
return
|
||||
self._enable_sensitive_data = value
|
||||
|
||||
@property
|
||||
def ENABLED(self) -> bool:
|
||||
"""Check if model diagnostics are enabled.
|
||||
@@ -706,6 +770,17 @@ class ObservabilitySettings:
|
||||
"""Check if the setup has been executed."""
|
||||
return self._executed_setup
|
||||
|
||||
@property
|
||||
def is_user_disabled(self) -> bool:
|
||||
"""Whether ``disable_instrumentation()`` has been called and the disable is still in effect.
|
||||
|
||||
Integrations that perform telemetry setup as a side-effect (e.g. provisioning Azure Monitor
|
||||
providers from a Foundry project's connection string) should consult this flag before doing
|
||||
their setup work, so the user's explicit opt-out is respected end-to-end and not just at the
|
||||
framework's span-emission boundary.
|
||||
"""
|
||||
return self._user_disabled
|
||||
|
||||
def _configure(
|
||||
self,
|
||||
*,
|
||||
@@ -951,24 +1026,91 @@ def _read_int_env(name: str, *, default: int | None = None) -> int | None:
|
||||
return default
|
||||
|
||||
|
||||
def enable_sensitive_telemetry(*, force: bool = False) -> None:
|
||||
"""Enable capture of sensitive data in telemetry for your application.
|
||||
|
||||
Instrumentation is enabled by default; this method exists to opt-in to capturing
|
||||
sensitive event payloads (e.g., chat messages, tool arguments).
|
||||
|
||||
This method does not configure exporters or providers. It also ensures that
|
||||
instrumentation is enabled (in case it was explicitly disabled via the
|
||||
ENABLE_INSTRUMENTATION environment variable).
|
||||
|
||||
Keyword Args:
|
||||
force: When True, clears any sticky disable previously set by
|
||||
``disable_instrumentation()`` before enabling. Without it, calls are
|
||||
no-ops if instrumentation has been explicitly disabled.
|
||||
|
||||
Warning:
|
||||
Sensitive events should only be enabled on test and development environments.
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
|
||||
logger.info(
|
||||
"enable_sensitive_telemetry() ignored: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Pass force=True to re-enable."
|
||||
)
|
||||
return
|
||||
if force:
|
||||
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = True
|
||||
|
||||
|
||||
def disable_instrumentation() -> None:
|
||||
"""Explicitly disable Agent Framework instrumentation for this process.
|
||||
|
||||
The disable is **sticky**: subsequent attempts by framework auto-setup paths,
|
||||
library integrations, ``enable_instrumentation()``, ``enable_sensitive_telemetry()``,
|
||||
``configure_otel_providers()``, or direct writes to
|
||||
``OBSERVABILITY_SETTINGS.enable_instrumentation`` are ignored and no spans, metrics,
|
||||
or logs are emitted by Agent Framework code paths.
|
||||
|
||||
To override the disable later, call ``enable_instrumentation(force=True)`` or
|
||||
``enable_sensitive_telemetry(force=True)``. This makes the user's intent to opt out
|
||||
win against framework code that would otherwise re-enable instrumentation
|
||||
automatically.
|
||||
|
||||
Note:
|
||||
Disabling does not tear down already-configured OpenTelemetry providers,
|
||||
exporters, or in-flight spans; it gates future captures by Agent Framework
|
||||
instrumentation only. To stop emitting telemetry from third-party
|
||||
instrumentations as well, configure them separately.
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
OBSERVABILITY_SETTINGS._user_disabled = True # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._enable_instrumentation = False # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._enable_sensitive_data = False # type: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
def enable_instrumentation(
|
||||
*,
|
||||
enable_sensitive_data: bool | None = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Enable instrumentation for your application.
|
||||
"""Enable instrumentation for Microsoft Agent Framework.
|
||||
|
||||
Calling this method implies you want to enable observability in your application.
|
||||
|
||||
This method does not configure exporters or providers.
|
||||
It only updates the global variables that trigger the instrumentation code.
|
||||
If you have already set the environment variable ENABLE_INSTRUMENTATION=true,
|
||||
calling this method has no effect, unless you want to enable or disable sensitive data events.
|
||||
Note that instrumentation is enabled by default, so this method is only necessary
|
||||
if you need a programmatic way to enable it (e.g., if you are not sure whether the
|
||||
environment variable ENABLE_INSTRUMENTATION is set to True or False and want to
|
||||
ensure it is enabled).
|
||||
|
||||
Keyword Args:
|
||||
enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides
|
||||
the environment variable ENABLE_SENSITIVE_DATA if set. Default is None.
|
||||
force: When True, clears any sticky disable previously set by
|
||||
``disable_instrumentation()`` before enabling. Without it, calls are
|
||||
no-ops if instrumentation has been explicitly disabled.
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
|
||||
logger.info(
|
||||
"enable_instrumentation() ignored: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Pass force=True to re-enable."
|
||||
)
|
||||
return
|
||||
if force:
|
||||
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
if enable_sensitive_data is not None:
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data
|
||||
@@ -1008,7 +1150,7 @@ def configure_otel_providers(
|
||||
Since you can only setup one provider per signal type (logs, traces, metrics),
|
||||
you can choose to use this method and take the exporter and provider that we created.
|
||||
Alternatively, you can setup the providers yourself, or through another library
|
||||
(e.g., Azure Monitor) and just call `enable_instrumentation()` to enable instrumentation.
|
||||
(e.g., Azure Monitor) and just call `enable_sensitive_telemetry()` to opt-in to sensitive data capture.
|
||||
|
||||
Note:
|
||||
By default, the Agent Framework emits metrics with the prefixes `agent_framework`
|
||||
@@ -1042,7 +1184,6 @@ def configure_otel_providers(
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
|
||||
# Using environment variables (recommended)
|
||||
# Set ENABLE_INSTRUMENTATION=true
|
||||
# Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
configure_otel_providers()
|
||||
|
||||
@@ -1087,18 +1228,25 @@ def configure_otel_providers(
|
||||
.. code-block:: python
|
||||
|
||||
# when azure monitor is installed
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
|
||||
connection_string = "InstrumentationKey=your_instrumentation_key_here;..."
|
||||
configure_azure_monitor(connection_string=connection_string)
|
||||
enable_instrumentation()
|
||||
# Optional: opt into capturing sensitive data
|
||||
enable_sensitive_telemetry()
|
||||
|
||||
References:
|
||||
- https://opentelemetry.io/docs/languages/sdk-configuration/general/
|
||||
- https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
if OBSERVABILITY_SETTINGS._user_disabled: # type: ignore[reportPrivateUsage]
|
||||
logger.info(
|
||||
"configure_otel_providers(): instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(); providers and exporters will still be configured but "
|
||||
"Agent Framework will emit no telemetry until enable_instrumentation(force=True) is called."
|
||||
)
|
||||
if env_file_path:
|
||||
# Build kwargs, excluding None values
|
||||
settings_kwargs: dict[str, Any] = {
|
||||
@@ -1280,7 +1428,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
if stream:
|
||||
span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL)
|
||||
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1344,6 +1492,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
|
||||
and isinstance(response, ChatResponse)
|
||||
and response.messages
|
||||
and span.is_recording()
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
@@ -1374,7 +1523,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
|
||||
async def _get_response() -> ChatResponse:
|
||||
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.REQUEST_MODEL) as span:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1408,7 +1557,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
duration=duration,
|
||||
)
|
||||
_mark_inner_response_telemetry_captured(response)
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
|
||||
finish_reason = cast(
|
||||
"FinishReason | None",
|
||||
response.finish_reason if response.finish_reason in FINISH_REASON_MAP else None,
|
||||
@@ -1552,7 +1701,7 @@ class AgentTelemetryLayer:
|
||||
if stream:
|
||||
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)
|
||||
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1613,6 +1762,7 @@ class AgentTelemetryLayer:
|
||||
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
|
||||
and isinstance(response, AgentResponse)
|
||||
and response.messages
|
||||
and span.is_recording()
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
@@ -1645,7 +1795,7 @@ class AgentTelemetryLayer:
|
||||
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:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1669,7 +1819,7 @@ class AgentTelemetryLayer:
|
||||
)
|
||||
_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:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
|
||||
@@ -4031,14 +4031,102 @@ async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompt
|
||||
assert tool._prompts_loaded is True
|
||||
|
||||
|
||||
async def test_connect_skips_tools_and_prompts_when_server_does_not_advertise_capabilities() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
|
||||
tool.is_connected = True
|
||||
tool.session = Mock()
|
||||
tool.session._request_id = 0
|
||||
tool.session.initialize = AsyncMock(
|
||||
return_value=types.InitializeResult(
|
||||
protocolVersion=types.LATEST_PROTOCOL_VERSION,
|
||||
capabilities=types.ServerCapabilities(),
|
||||
serverInfo=types.Implementation(name="test", version="1.0"),
|
||||
)
|
||||
)
|
||||
tool.session.list_tools = AsyncMock()
|
||||
tool.session.list_prompts = AsyncMock()
|
||||
tool.session.set_logging_level = AsyncMock()
|
||||
|
||||
with patch.object(logger, "level", logging.INFO):
|
||||
await tool._connect_on_owner()
|
||||
|
||||
tool.session.initialize.assert_awaited_once()
|
||||
tool.session.list_tools.assert_not_called()
|
||||
tool.session.list_prompts.assert_not_called()
|
||||
tool.session.set_logging_level.assert_not_called()
|
||||
assert tool.is_connected is True
|
||||
assert tool._supports_tools is False
|
||||
assert tool._supports_prompts is False
|
||||
assert tool._supports_logging is False
|
||||
assert tool._tools_loaded is True
|
||||
assert tool._prompts_loaded is True
|
||||
|
||||
|
||||
async def test_connect_treats_missing_capabilities_as_unsupported() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
|
||||
tool.is_connected = True
|
||||
tool.session = Mock()
|
||||
tool.session._request_id = 0
|
||||
tool.session.initialize = AsyncMock(return_value=Mock(capabilities=None))
|
||||
tool.session.list_tools = AsyncMock()
|
||||
tool.session.list_prompts = AsyncMock()
|
||||
|
||||
with patch.object(logger, "level", logging.NOTSET):
|
||||
await tool._connect_on_owner()
|
||||
|
||||
tool.session.list_tools.assert_not_called()
|
||||
tool.session.list_prompts.assert_not_called()
|
||||
assert tool._supports_tools is False
|
||||
assert tool._supports_prompts is False
|
||||
assert tool._supports_logging is False
|
||||
|
||||
|
||||
async def test_connect_sets_logging_level_when_server_advertises_logging() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=False, load_prompts=False)
|
||||
tool.is_connected = True
|
||||
tool.session = Mock()
|
||||
tool.session._request_id = 0
|
||||
tool.session.initialize = AsyncMock(
|
||||
return_value=types.InitializeResult(
|
||||
protocolVersion=types.LATEST_PROTOCOL_VERSION,
|
||||
capabilities=types.ServerCapabilities(logging=types.LoggingCapability()),
|
||||
serverInfo=types.Implementation(name="test", version="1.0"),
|
||||
)
|
||||
)
|
||||
tool.session.set_logging_level = AsyncMock()
|
||||
|
||||
with patch.object(logger, "level", logging.INFO):
|
||||
await tool._connect_on_owner()
|
||||
|
||||
tool.session.set_logging_level.assert_awaited_once_with("info")
|
||||
assert tool._supports_logging is True
|
||||
|
||||
|
||||
async def test_ensure_connected_skips_future_pings_when_ping_is_not_available() -> None:
|
||||
tool = MCPTool(name="test_tool")
|
||||
tool.session = Mock(
|
||||
send_ping=AsyncMock(
|
||||
side_effect=McpError(types.ErrorData(code=-32601, message="Method 'ping' is not available."))
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock()) as mock_reconnect:
|
||||
await tool._ensure_connected()
|
||||
await tool._ensure_connected()
|
||||
|
||||
tool.session.send_ping.assert_awaited_once()
|
||||
mock_reconnect.assert_not_awaited()
|
||||
assert tool._ping_available is False
|
||||
|
||||
|
||||
async def test_ensure_connected_reconnects_on_failed_ping() -> None:
|
||||
tool = MCPTool(name="test_tool")
|
||||
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
|
||||
|
||||
with patch.object(tool, "connect", AsyncMock()) as mock_connect:
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock()) as mock_reconnect:
|
||||
await tool._ensure_connected()
|
||||
|
||||
mock_connect.assert_awaited_once_with(reset=True)
|
||||
mock_reconnect.assert_awaited_once_with()
|
||||
|
||||
|
||||
async def test_ensure_connected_wraps_reconnect_failure() -> None:
|
||||
@@ -4046,12 +4134,70 @@ async def test_ensure_connected_wraps_reconnect_failure() -> None:
|
||||
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
|
||||
|
||||
with (
|
||||
patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("still closed"))),
|
||||
patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=RuntimeError("still closed"))),
|
||||
pytest.raises(ToolExecutionException, match="Failed to establish MCP connection"),
|
||||
):
|
||||
await tool._ensure_connected()
|
||||
|
||||
|
||||
async def test_load_tools_reconnects_on_closed_resource_when_ping_is_unavailable() -> None:
|
||||
from anyio import ClosedResourceError
|
||||
|
||||
tool = MCPTool(name="test_tool", load_tools=True)
|
||||
tool._ping_available = False
|
||||
|
||||
first_session = Mock()
|
||||
first_session.list_tools = AsyncMock(side_effect=ClosedResourceError())
|
||||
tool.session = first_session
|
||||
|
||||
page = Mock()
|
||||
page.tools = []
|
||||
page.nextCursor = None
|
||||
|
||||
second_session = Mock()
|
||||
second_session.list_tools = AsyncMock(return_value=page)
|
||||
|
||||
async def reconnect() -> None:
|
||||
tool.session = second_session
|
||||
tool._supports_tools = True
|
||||
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=reconnect)) as mock_reconnect:
|
||||
await tool.load_tools()
|
||||
|
||||
first_session.list_tools.assert_awaited_once()
|
||||
mock_reconnect.assert_awaited_once_with()
|
||||
second_session.list_tools.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_load_prompts_reconnects_on_closed_resource_when_ping_is_unavailable() -> None:
|
||||
from anyio import ClosedResourceError
|
||||
|
||||
tool = MCPTool(name="test_tool", load_prompts=True)
|
||||
tool._ping_available = False
|
||||
|
||||
first_session = Mock()
|
||||
first_session.list_prompts = AsyncMock(side_effect=ClosedResourceError())
|
||||
tool.session = first_session
|
||||
|
||||
page = Mock()
|
||||
page.prompts = []
|
||||
page.nextCursor = None
|
||||
|
||||
second_session = Mock()
|
||||
second_session.list_prompts = AsyncMock(return_value=page)
|
||||
|
||||
async def reconnect() -> None:
|
||||
tool.session = second_session
|
||||
tool._supports_prompts = True
|
||||
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=reconnect)) as mock_reconnect:
|
||||
await tool.load_prompts()
|
||||
|
||||
first_session.list_prompts.assert_awaited_once()
|
||||
mock_reconnect.assert_awaited_once_with()
|
||||
second_session.list_prompts.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_filters_framework_kwargs():
|
||||
"""Test that call_tool filters out framework-specific kwargs before calling MCP session.
|
||||
|
||||
|
||||
@@ -1015,11 +1015,25 @@ def test_observability_settings_is_setup_initial(monkeypatch):
|
||||
assert settings.is_setup is False
|
||||
|
||||
|
||||
# region Test enable_instrumentation function
|
||||
def test_enable_sensitive_telemetry_function(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry function enables instrumentation."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_function(monkeypatch):
|
||||
"""Test enable_instrumentation function enables instrumentation."""
|
||||
"""Test enable_instrumentation function enables instrumentation when disabled via env."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
@@ -1032,10 +1046,12 @@ def test_enable_instrumentation_function(monkeypatch):
|
||||
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
# Sensitive data should remain False when not explicitly enabled
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_with_sensitive_data(monkeypatch):
|
||||
"""Test enable_instrumentation function with sensitive_data parameter."""
|
||||
"""Test enable_instrumentation function with explicit sensitive_data parameter."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
@@ -1049,111 +1065,6 @@ def test_enable_instrumentation_with_sensitive_data(monkeypatch):
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test enable_instrumentation re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
|
||||
|
||||
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
|
||||
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
|
||||
|
||||
|
||||
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit parameters to configure_otel_providers override env vars."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_sensitive_data=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit enable_sensitive_data parameter to enable_instrumentation overrides env var."""
|
||||
import importlib
|
||||
@@ -1269,6 +1180,161 @@ def test_enable_instrumentation_preserves_console_exporters_after_env_removed(mo
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
|
||||
|
||||
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
|
||||
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
|
||||
|
||||
|
||||
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit parameters to configure_otel_providers override env vars."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_sensitive_data=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_does_not_touch_console_exporters(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry does not modify enable_console_exporters (it is an exporter concern)."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
observability.enable_sensitive_telemetry()
|
||||
# enable_console_exporters is not managed by enable_sensitive_telemetry;
|
||||
# it is only read by configure_otel_providers.
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_does_not_clobber_console_exporters(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry does not reset enable_console_exporters set by prior configure call."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Set console exporters via configure_otel_providers
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Calling enable_sensitive_telemetry should not clobber the value
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_preserves_console_exporters_after_env_removed(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry preserves enable_console_exporters when env var is removed after reload."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Remove the env var after reload
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
|
||||
# enable_sensitive_telemetry should not reset the value
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_console_exporters(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_CONSOLE_EXPORTERS from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
@@ -1321,6 +1387,189 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
|
||||
# region Test default-on instrumentation
|
||||
|
||||
|
||||
def test_observability_settings_defaults_instrumentation_true(monkeypatch):
|
||||
"""ENABLE_INSTRUMENTATION unset → ObservabilitySettings defaults to True."""
|
||||
from agent_framework.observability import ObservabilitySettings
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
settings = ObservabilitySettings()
|
||||
assert settings.enable_instrumentation is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
|
||||
"""No-arg enable_instrumentation() re-reads ENABLE_SENSITIVE_DATA from env at call time.
|
||||
|
||||
Covers the fallback branch where the env var is set AFTER import (e.g. via load_dotenv()).
|
||||
"""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Simulate load_dotenv() setting the env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
observability.enable_instrumentation()
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
# region Test disable_instrumentation sticky behavior
|
||||
|
||||
|
||||
def test_disable_instrumentation_flips_settings_off(monkeypatch):
|
||||
"""disable_instrumentation() immediately turns instrumentation and sensitive data off."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is True
|
||||
|
||||
observability.disable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.ENABLED is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_is_sticky_against_enable_instrumentation(monkeypatch):
|
||||
"""Sticky disable: enable_instrumentation() without force is a no-op after disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation(enable_sensitive_data=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_is_sticky_against_enable_sensitive_telemetry(monkeypatch):
|
||||
"""Sticky disable: enable_sensitive_telemetry() without force is a no-op after disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_is_sticky_against_configure_otel_providers(monkeypatch):
|
||||
"""Sticky disable: configure_otel_providers() does not flip instrumentation back on."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_sensitive_data=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_intercepts_direct_attribute_writes(monkeypatch):
|
||||
"""Sticky disable: direct OBSERVABILITY_SETTINGS.enable_instrumentation = True is intercepted."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
observability.OBSERVABILITY_SETTINGS.enable_sensitive_data = True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_force_clears_disable(monkeypatch):
|
||||
"""enable_instrumentation(force=True) clears the sticky disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation(force=True, enable_sensitive_data=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_force_clears_disable(monkeypatch):
|
||||
"""enable_sensitive_telemetry(force=True) clears the sticky disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_sensitive_telemetry(force=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_disable_instrumentation_persists_after_force_until_redisabled(monkeypatch):
|
||||
"""After force-enable then disable again, the sticky disable is re-armed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation(force=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_in_all(monkeypatch):
|
||||
"""disable_instrumentation must be re-exported from the module's __all__."""
|
||||
import agent_framework.observability as observability
|
||||
|
||||
assert "disable_instrumentation" in observability.__all__
|
||||
assert callable(observability.disable_instrumentation)
|
||||
|
||||
|
||||
# region Test _to_otel_part content types
|
||||
|
||||
|
||||
@@ -3797,3 +4046,135 @@ async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvar
|
||||
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
assert len(agent_spans) == 1
|
||||
assert agent_spans[0].status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
# region Test heavy operations skipped when span is not recording
|
||||
#
|
||||
# When ``ENABLE_INSTRUMENTATION`` is on (the default) but no OpenTelemetry
|
||||
# tracer provider has been configured, the global provider is the
|
||||
# ``ProxyTracerProvider`` which returns non-recording spans. The telemetry
|
||||
# layers gate sensitive-data serialization (``_capture_messages``) on
|
||||
# ``span.is_recording()`` so that we don't pay the JSON-serialization cost
|
||||
# when the span is going to be dropped anyway. The tests below verify that
|
||||
# behavior by patching ``get_tracer`` to return a ``NoOpTracer``.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Heavy message serialization is skipped when no provider is configured (non-streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
client = mock_chat_client()
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
response = await client.get_response(messages=messages, options={"model": "Test"})
|
||||
|
||||
assert response is not None
|
||||
# Sensitive-data serialization must be skipped because span.is_recording() is False.
|
||||
assert mock_capture_messages.call_count == 0
|
||||
# _capture_response still runs so that metric histograms continue to record.
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_streaming_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Heavy message serialization is skipped when no provider is configured (streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
client = mock_chat_client()
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
updates: list[ChatResponseUpdate] = []
|
||||
stream = client.get_response(messages=messages, stream=True, options={"model": "Test"})
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await stream.get_final_response()
|
||||
|
||||
assert len(updates) == 2
|
||||
assert mock_capture_messages.call_count == 0
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Agent heavy serialization is skipped when no provider is configured (non-streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
agent = mock_chat_agent()
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
response = await agent.run("Test message")
|
||||
|
||||
assert response is not None
|
||||
assert mock_capture_messages.call_count == 0
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_streaming_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Agent heavy serialization is skipped when no provider is configured (streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
agent = mock_chat_agent()
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
updates: list[Any] = []
|
||||
stream = agent.run("Test message", stream=True)
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await stream.get_final_response()
|
||||
|
||||
assert len(updates) == 2
|
||||
assert mock_capture_messages.call_count == 0
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_capture_messages_called_when_span_recording(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Sanity check: with a real recording provider, sensitive-data capture still runs."""
|
||||
client = mock_chat_client()
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
response = await client.get_response(messages=messages, options={"model": "Test"})
|
||||
|
||||
assert response is not None
|
||||
# Two _capture_messages calls: one for input, one for output messages.
|
||||
assert mock_capture_messages.call_count == 2
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
@@ -39,3 +39,70 @@ async with Agent(
|
||||
result = await agent.run("What tools are available?")
|
||||
print(result.text)
|
||||
```
|
||||
|
||||
## Hosted tool factories
|
||||
|
||||
`FoundryChatClient` exposes static factory methods that return Foundry SDK tool
|
||||
configurations ready to pass to an `Agent`'s `tools=[...]` argument. These
|
||||
factories don't require a `FoundryChatClient` instance — you can call them
|
||||
statically and reuse the same tool configuration across agents.
|
||||
|
||||
```python
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(...),
|
||||
instructions="...",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
FoundryChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Generally available factories: `get_code_interpreter_tool`,
|
||||
`get_file_search_tool`, `get_web_search_tool`,
|
||||
`get_image_generation_tool`, `get_mcp_tool`.
|
||||
|
||||
> **Choosing a web grounding tool.** `get_web_search_tool` is the recommended
|
||||
> default — it requires no separate Bing resource and works with Azure OpenAI
|
||||
> models out of the box. Reach for `get_bing_grounding_tool` (experimental,
|
||||
> see below) when you need finer Bing parameters (`count`, `freshness`,
|
||||
> `market`, `set_lang`), are grounding non-OpenAI Foundry models, or are
|
||||
> migrating from Grounding with Bing Search on the classic platform — it
|
||||
> requires a Grounding with Bing Search Azure resource that you manage.
|
||||
> `get_bing_custom_search_tool` (also experimental) is for grounding
|
||||
> restricted to a curated list of domains via a Bing Custom Search instance.
|
||||
> See the
|
||||
> [web grounding overview](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview)
|
||||
> for the full comparison.
|
||||
|
||||
> **Experimental — `ExperimentalFeature.FOUNDRY_TOOLS`.** The following
|
||||
> factories wrap GA Foundry tool SDK classes but are new wrappers in
|
||||
> `agent-framework-foundry` and may change before the wrappers themselves
|
||||
> reach GA. Calls emit an `ExperimentalWarning` the first time the
|
||||
> `FOUNDRY_TOOLS` feature is exercised in a process (then deduplicated).
|
||||
|
||||
| Factory | Foundry SDK tool |
|
||||
|---------|-----------------|
|
||||
| `get_azure_ai_search_tool(index_connection_id, index_name, ...)` | `AzureAISearchTool` |
|
||||
| `get_bing_grounding_tool(connection_id, ...)` | `BingGroundingTool` |
|
||||
|
||||
> **Experimental — `ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS`.** The
|
||||
> following factories wrap **preview** Foundry tool SDK types — the underlying
|
||||
> Foundry capability itself is in preview and may change or be removed before
|
||||
> reaching GA. Calls emit a separate `ExperimentalWarning` the first time the
|
||||
> `FOUNDRY_PREVIEW_TOOLS` feature is exercised in a process (then
|
||||
> deduplicated). Use `FOUNDRY_TOOLS` for "wrapper is new" and
|
||||
> `FOUNDRY_PREVIEW_TOOLS` for "underlying Foundry feature is preview".
|
||||
|
||||
| Factory | Foundry SDK tool |
|
||||
|---------|-----------------|
|
||||
| `get_sharepoint_tool(connection_id)` | `SharepointPreviewTool` |
|
||||
| `get_fabric_tool(connection_id)` | `MicrosoftFabricPreviewTool` |
|
||||
| `get_memory_search_tool(memory_store_name, scope, ...)` | `MemorySearchPreviewTool` |
|
||||
| `get_computer_use_tool(environment, display_width, display_height)` | `ComputerUsePreviewTool` |
|
||||
| `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` |
|
||||
| `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` |
|
||||
| `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` |
|
||||
|
||||
@@ -793,8 +793,22 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
Raises:
|
||||
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
|
||||
"""
|
||||
from agent_framework.observability import (
|
||||
OBSERVABILITY_SETTINGS,
|
||||
create_metric_views,
|
||||
create_resource,
|
||||
enable_instrumentation,
|
||||
)
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
if OBSERVABILITY_SETTINGS.is_user_disabled:
|
||||
logger.info(
|
||||
"FoundryAgent.configure_azure_monitor(): Skipping setup because instrumentation was "
|
||||
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
|
||||
"to re-enable, then re-invoke configure_azure_monitor()."
|
||||
)
|
||||
return
|
||||
|
||||
client = self.client
|
||||
if not isinstance(client, RawFoundryAgentChatClient):
|
||||
raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.")
|
||||
@@ -817,8 +831,6 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
"Install it with: pip install azure-monitor-opentelemetry"
|
||||
) from exc
|
||||
|
||||
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
|
||||
|
||||
if "resource" not in kwargs:
|
||||
kwargs["resource"] = create_resource()
|
||||
|
||||
|
||||
@@ -16,14 +16,35 @@ from agent_framework import (
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
A2APreviewTool,
|
||||
AISearchIndexResource,
|
||||
AutoCodeInterpreterToolParam,
|
||||
AzureAISearchTool,
|
||||
AzureAISearchToolResource,
|
||||
BingCustomSearchConfiguration,
|
||||
BingCustomSearchPreviewTool,
|
||||
BingCustomSearchToolParameters,
|
||||
BingGroundingSearchConfiguration,
|
||||
BingGroundingSearchToolParameters,
|
||||
BingGroundingTool,
|
||||
BrowserAutomationPreviewTool,
|
||||
BrowserAutomationToolConnectionParameters,
|
||||
BrowserAutomationToolParameters,
|
||||
CodeInterpreterTool,
|
||||
ComputerUsePreviewTool,
|
||||
FabricDataAgentToolParameters,
|
||||
ImageGenTool,
|
||||
MemorySearchPreviewTool,
|
||||
MicrosoftFabricPreviewTool,
|
||||
SharepointGroundingToolParameters,
|
||||
SharepointPreviewTool,
|
||||
ToolProjectConnection,
|
||||
WebSearchApproximateLocation,
|
||||
WebSearchTool,
|
||||
WebSearchToolFilters,
|
||||
@@ -271,8 +292,22 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
Raises:
|
||||
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
|
||||
"""
|
||||
from agent_framework.observability import (
|
||||
OBSERVABILITY_SETTINGS,
|
||||
create_metric_views,
|
||||
create_resource,
|
||||
enable_instrumentation,
|
||||
)
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
if OBSERVABILITY_SETTINGS.is_user_disabled:
|
||||
logger.info(
|
||||
"FoundryChatClient.configure_azure_monitor(): Skipping setup because instrumentation was "
|
||||
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
|
||||
"to re-enable, then re-invoke configure_azure_monitor()."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
|
||||
except ResourceNotFoundError:
|
||||
@@ -291,8 +326,6 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
"Install it with: pip install azure-monitor-opentelemetry"
|
||||
) from exc
|
||||
|
||||
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
|
||||
|
||||
if "resource" not in kwargs:
|
||||
kwargs["resource"] = create_resource()
|
||||
|
||||
@@ -369,17 +402,44 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
custom_search_configuration: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> WebSearchTool:
|
||||
"""Create a web search tool configuration for Microsoft Foundry.
|
||||
"""Create a Web Search tool configuration for Microsoft Foundry.
|
||||
|
||||
**Choosing a web grounding tool.** Foundry exposes three options that all reach
|
||||
the public web via Bing. Pick the one that matches your scenario:
|
||||
|
||||
* :py:meth:`get_web_search_tool` (this one, GA) — recommended starting point.
|
||||
The Bing resource is managed by Microsoft, no extra Azure setup is required,
|
||||
and only Azure OpenAI models are supported. Parameters are limited to
|
||||
``user_location`` and ``search_context_size``.
|
||||
* :py:meth:`get_bing_grounding_tool` (preview) — use when you need finer Bing parameters (``count``,
|
||||
``freshness``, ``market``, ``set_lang``), want to ground non-OpenAI
|
||||
Foundry models, or are migrating from Grounding with Bing Search on the
|
||||
classic agents platform. You manage the Grounding with Bing Search
|
||||
resource yourself (Contributor/Owner to create the resource, Foundry
|
||||
Project Manager to wire the connection).
|
||||
* :py:meth:`get_bing_custom_search_tool` (preview) — use when you need to
|
||||
restrict grounding to a curated set of domains defined in a Bing Custom
|
||||
Search instance.
|
||||
|
||||
For all three, search data flows outside the Azure compliance boundary. See
|
||||
https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview for
|
||||
the full comparison.
|
||||
|
||||
Keyword Args:
|
||||
user_location: Location context with keys like "city", "country", "region", "timezone".
|
||||
search_context_size: Amount of context from search results ("low", "medium", "high").
|
||||
allowed_domains: List of domains to restrict search results to.
|
||||
custom_search_configuration: Custom Bing search configuration.
|
||||
**kwargs: Additional arguments passed to the SDK WebSearchTool constructor.
|
||||
user_location: Location context with keys like ``"city"``, ``"country"``,
|
||||
``"region"``, ``"timezone"``.
|
||||
search_context_size: Amount of context from search results
|
||||
(``"low"``, ``"medium"``, ``"high"``).
|
||||
allowed_domains: List of domains to restrict search results to. Wrapped
|
||||
into ``WebSearchToolFilters`` and passed as the ``filters`` field on
|
||||
the SDK ``WebSearchTool``.
|
||||
custom_search_configuration: Custom Bing search configuration for
|
||||
domain-restricted scenarios.
|
||||
**kwargs: Additional arguments passed to the SDK ``WebSearchTool``
|
||||
constructor.
|
||||
|
||||
Returns:
|
||||
A WebSearchTool ready to pass to an Agent.
|
||||
A ``WebSearchTool`` ready to pass to an Agent.
|
||||
"""
|
||||
ws_kwargs: dict[str, Any] = {**kwargs}
|
||||
if search_context_size:
|
||||
@@ -388,15 +448,137 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
ws_kwargs["filters"] = WebSearchToolFilters(allowed_domains=allowed_domains)
|
||||
if custom_search_configuration:
|
||||
ws_kwargs["custom_search_configuration"] = custom_search_configuration
|
||||
ws_tool = WebSearchTool(**ws_kwargs)
|
||||
if user_location:
|
||||
ws_tool.user_location = WebSearchApproximateLocation(
|
||||
ws_kwargs["user_location"] = WebSearchApproximateLocation(
|
||||
city=user_location.get("city"),
|
||||
country=user_location.get("country"),
|
||||
region=user_location.get("region"),
|
||||
timezone=user_location.get("timezone"),
|
||||
)
|
||||
return ws_tool
|
||||
return WebSearchTool(**ws_kwargs)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS)
|
||||
def get_bing_grounding_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
market: str | None = None,
|
||||
set_lang: str | None = None,
|
||||
count: int | None = None,
|
||||
freshness: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BingGroundingTool:
|
||||
"""Create a Grounding with Bing Search tool configuration for Foundry.
|
||||
|
||||
Use this factory when :py:meth:`get_web_search_tool` is too restrictive — for
|
||||
example when you need ``count``/``freshness``/``market``/``set_lang``
|
||||
parameters, want to ground a non-OpenAI Foundry model, or are migrating an
|
||||
agent that already uses Grounding with Bing Search on the classic agents
|
||||
platform. You manage the Grounding with Bing Search Azure resource yourself
|
||||
(Contributor or Owner to create the resource, Foundry Project Manager to
|
||||
create the project connection). Search data flows outside the Azure
|
||||
compliance boundary.
|
||||
|
||||
For domain-restricted grounding to a curated allow-list, use
|
||||
:py:meth:`get_bing_custom_search_tool` instead. For a zero-setup default that
|
||||
works for most agents, see :py:meth:`get_web_search_tool`. The full
|
||||
comparison lives at
|
||||
https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Grounding with
|
||||
Bing Search resource.
|
||||
market: Optional Bing market identifier (e.g. ``"en-US"``).
|
||||
set_lang: Optional UI language code passed to the Bing API.
|
||||
count: Optional number of search results to return.
|
||||
freshness: Optional time-range filter for search results. See
|
||||
https://learn.microsoft.com/bing/search-apis/bing-web-search/reference/query-parameters
|
||||
for accepted values.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``BingGroundingSearchConfiguration``.
|
||||
|
||||
Returns:
|
||||
A ``BingGroundingTool`` ready to pass to an Agent.
|
||||
"""
|
||||
config_kwargs: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"project_connection_id": connection_id,
|
||||
}
|
||||
if market is not None:
|
||||
config_kwargs["market"] = market
|
||||
if set_lang is not None:
|
||||
config_kwargs["set_lang"] = set_lang
|
||||
if count is not None:
|
||||
config_kwargs["count"] = count
|
||||
if freshness is not None:
|
||||
config_kwargs["freshness"] = freshness
|
||||
return BingGroundingTool(
|
||||
bing_grounding=BingGroundingSearchToolParameters(
|
||||
search_configurations=[BingGroundingSearchConfiguration(**config_kwargs)],
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_bing_custom_search_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
instance_name: str,
|
||||
market: str | None = None,
|
||||
set_lang: str | None = None,
|
||||
count: int | None = None,
|
||||
freshness: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BingCustomSearchPreviewTool:
|
||||
"""Create a Grounding with Bing Custom Search tool configuration for Foundry.
|
||||
|
||||
Use this factory (preview) when you need to restrict grounding to a curated
|
||||
list of domains. The allow/block list is defined ahead of time on a Bing
|
||||
Custom Search resource (in the Bing portal) and referenced here by
|
||||
``instance_name``. Like the other Bing-backed tools, search data flows
|
||||
outside the Azure compliance boundary, and you must create the Bing Custom
|
||||
Search resource yourself.
|
||||
|
||||
For unrestricted public-web grounding with no extra Azure setup, prefer
|
||||
:py:meth:`get_web_search_tool`. For unrestricted grounding with finer Bing
|
||||
parameters or non-OpenAI models, prefer :py:meth:`get_bing_grounding_tool`.
|
||||
See
|
||||
https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview
|
||||
for the full comparison.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Grounding with
|
||||
Bing Custom Search resource.
|
||||
instance_name: The custom configuration instance name defined on the
|
||||
Bing Custom Search resource.
|
||||
market: Optional Bing market identifier (e.g. ``"en-US"``).
|
||||
set_lang: Optional UI language code passed to the Bing API.
|
||||
count: Optional number of search results to return.
|
||||
freshness: Optional time-range filter for search results.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``BingCustomSearchConfiguration``.
|
||||
|
||||
Returns:
|
||||
A ``BingCustomSearchPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
config_kwargs: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"project_connection_id": connection_id,
|
||||
"instance_name": instance_name,
|
||||
}
|
||||
if market is not None:
|
||||
config_kwargs["market"] = market
|
||||
if set_lang is not None:
|
||||
config_kwargs["set_lang"] = set_lang
|
||||
if count is not None:
|
||||
config_kwargs["count"] = count
|
||||
if freshness is not None:
|
||||
config_kwargs["freshness"] = freshness
|
||||
return BingCustomSearchPreviewTool(
|
||||
bing_custom_search_preview=BingCustomSearchToolParameters(
|
||||
search_configurations=[BingCustomSearchConfiguration(**config_kwargs)],
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_image_generation_tool( # type: ignore[override]
|
||||
@@ -501,6 +683,219 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
|
||||
# endregion
|
||||
|
||||
# region Experimental Foundry tool factories (preview SDK types)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_TOOLS)
|
||||
def get_azure_ai_search_tool(
|
||||
*,
|
||||
index_connection_id: str,
|
||||
index_name: str,
|
||||
query_type: str | None = None,
|
||||
top_k: int | None = None,
|
||||
filter: str | None = None,
|
||||
index_asset_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AzureAISearchTool:
|
||||
"""Create an Azure AI Search tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
index_connection_id: The Foundry project connection ID for the Azure AI Search index.
|
||||
index_name: The name of the index to search.
|
||||
query_type: Optional query type (``"simple"``, ``"semantic"``, ``"vector"``,
|
||||
``"vector_simple_hybrid"``, or ``"vector_semantic_hybrid"``).
|
||||
top_k: Optional number of documents to retrieve.
|
||||
filter: Optional OData filter expression.
|
||||
index_asset_id: Optional index asset id for the search resource.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``AISearchIndexResource``.
|
||||
|
||||
Returns:
|
||||
An ``AzureAISearchTool`` ready to pass to an Agent.
|
||||
"""
|
||||
index_kwargs: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"project_connection_id": index_connection_id,
|
||||
"index_name": index_name,
|
||||
}
|
||||
if query_type is not None:
|
||||
index_kwargs["query_type"] = query_type
|
||||
if top_k is not None:
|
||||
index_kwargs["top_k"] = top_k
|
||||
if filter is not None:
|
||||
index_kwargs["filter"] = filter
|
||||
if index_asset_id is not None:
|
||||
index_kwargs["index_asset_id"] = index_asset_id
|
||||
return AzureAISearchTool(
|
||||
azure_ai_search=AzureAISearchToolResource(indexes=[AISearchIndexResource(**index_kwargs)]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_sharepoint_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
**kwargs: Any,
|
||||
) -> SharepointPreviewTool:
|
||||
"""Create a SharePoint grounding tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the SharePoint resource.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``SharepointGroundingToolParameters``.
|
||||
|
||||
Returns:
|
||||
A ``SharepointPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return SharepointPreviewTool(
|
||||
sharepoint_grounding_preview=SharepointGroundingToolParameters(
|
||||
project_connections=[ToolProjectConnection(project_connection_id=connection_id)],
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_fabric_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
**kwargs: Any,
|
||||
) -> MicrosoftFabricPreviewTool:
|
||||
"""Create a Microsoft Fabric data agent tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Fabric data agent.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``FabricDataAgentToolParameters``.
|
||||
|
||||
Returns:
|
||||
A ``MicrosoftFabricPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return MicrosoftFabricPreviewTool(
|
||||
fabric_dataagent_preview=FabricDataAgentToolParameters(
|
||||
project_connections=[ToolProjectConnection(project_connection_id=connection_id)],
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_memory_search_tool(
|
||||
*,
|
||||
memory_store_name: str,
|
||||
scope: str,
|
||||
search_options: Any | None = None,
|
||||
update_delay: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> MemorySearchPreviewTool:
|
||||
"""Create a Memory Search tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
memory_store_name: The name of the memory store to use.
|
||||
scope: The namespace used to group and isolate memories (e.g. a user ID).
|
||||
Use ``"{{$userId}}"`` to scope memories to the current signed-in user.
|
||||
search_options: Optional ``MemorySearchOptions`` instance.
|
||||
update_delay: Optional seconds to wait before updating memories after inactivity.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``MemorySearchPreviewTool``.
|
||||
|
||||
Returns:
|
||||
A ``MemorySearchPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
**kwargs,
|
||||
"memory_store_name": memory_store_name,
|
||||
"scope": scope,
|
||||
}
|
||||
if search_options is not None:
|
||||
params["search_options"] = search_options
|
||||
if update_delay is not None:
|
||||
params["update_delay"] = update_delay
|
||||
return MemorySearchPreviewTool(**params)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_computer_use_tool(
|
||||
*,
|
||||
environment: str,
|
||||
display_width: int,
|
||||
display_height: int,
|
||||
**kwargs: Any,
|
||||
) -> ComputerUsePreviewTool:
|
||||
"""Create a Computer Use tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
environment: The computer environment to control. One of ``"windows"``,
|
||||
``"mac"``, ``"linux"``, ``"ubuntu"``, or ``"browser"``.
|
||||
display_width: The width of the computer display.
|
||||
display_height: The height of the computer display.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``ComputerUsePreviewTool``.
|
||||
|
||||
Returns:
|
||||
A ``ComputerUsePreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return ComputerUsePreviewTool(
|
||||
environment=environment,
|
||||
display_width=display_width,
|
||||
display_height=display_height,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_browser_automation_tool(
|
||||
*,
|
||||
connection_id: str,
|
||||
**kwargs: Any,
|
||||
) -> BrowserAutomationPreviewTool:
|
||||
"""Create a Browser Automation tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
connection_id: The Foundry project connection ID for the Azure Playwright resource.
|
||||
**kwargs: Additional arguments forwarded to the SDK
|
||||
``BrowserAutomationToolParameters``.
|
||||
|
||||
Returns:
|
||||
A ``BrowserAutomationPreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
return BrowserAutomationPreviewTool(
|
||||
browser_automation_preview=BrowserAutomationToolParameters(
|
||||
connection=BrowserAutomationToolConnectionParameters(project_connection_id=connection_id),
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@experimental(feature_id=ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS)
|
||||
def get_a2a_tool(
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
agent_card_path: str | None = None,
|
||||
project_connection_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> A2APreviewTool:
|
||||
"""Create an Agent-to-Agent (A2A) tool configuration for Foundry.
|
||||
|
||||
Keyword Args:
|
||||
base_url: Base URL of the remote A2A agent.
|
||||
agent_card_path: Path to the agent card relative to ``base_url``.
|
||||
Defaults to ``"/.well-known/agent-card.json"`` server-side.
|
||||
project_connection_id: Foundry connection ID for the A2A server. Stores
|
||||
authentication and other connection details.
|
||||
**kwargs: Additional arguments forwarded to the SDK ``A2APreviewTool``.
|
||||
|
||||
Returns:
|
||||
An ``A2APreviewTool`` ready to pass to an Agent.
|
||||
"""
|
||||
params: dict[str, Any] = dict(kwargs)
|
||||
if base_url is not None:
|
||||
params["base_url"] = base_url
|
||||
if agent_card_path is not None:
|
||||
params["agent_card_path"] = agent_card_path
|
||||
if project_connection_id is not None:
|
||||
params["project_connection_id"] = project_connection_id
|
||||
return A2APreviewTool(**params)
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
class FoundryChatClient( # type: ignore[misc]
|
||||
FunctionInvocationLayer[FoundryChatOptionsT],
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
@@ -984,6 +985,25 @@ def test_get_web_search_tool_with_location() -> None:
|
||||
assert tool_obj is not None
|
||||
|
||||
|
||||
def test_get_web_search_tool_allowed_domains() -> None:
|
||||
"""allowed_domains is wrapped into the SDK filters field."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
tool_obj = RawFoundryChatClient.get_web_search_tool(allowed_domains=["example.com"])
|
||||
assert tool_obj.filters is not None
|
||||
assert tool_obj.filters.allowed_domains == ["example.com"]
|
||||
|
||||
|
||||
def test_get_web_search_tool_custom_search_configuration() -> None:
|
||||
"""custom_search_configuration is forwarded to the SDK without warning."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
tool_obj = RawFoundryChatClient.get_web_search_tool(
|
||||
custom_search_configuration={"connection_id": "c", "instance_name": "i"},
|
||||
)
|
||||
assert tool_obj.custom_search_configuration == {"connection_id": "c", "instance_name": "i"}
|
||||
|
||||
|
||||
def test_get_image_generation_tool() -> None:
|
||||
"""Test image generation tool creation."""
|
||||
|
||||
@@ -1012,6 +1032,223 @@ def test_get_mcp_tool_with_connection_id() -> None:
|
||||
assert tool_obj is not None
|
||||
|
||||
|
||||
def _skip_if_sdk_class_missing(name: str) -> Any:
|
||||
"""Return the SDK class or skip the test if older azure-ai-projects lacks it."""
|
||||
from azure.ai.projects import models as projects_models
|
||||
|
||||
cls = getattr(projects_models, name, None)
|
||||
if cls is None:
|
||||
pytest.skip(f"azure-ai-projects in this environment does not expose {name!r}.")
|
||||
return cls
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_azure_ai_search_tool() -> None:
|
||||
"""Azure AI Search tool factory builds the nested resource correctly."""
|
||||
azure_ai_search_tool_cls = _skip_if_sdk_class_missing("AzureAISearchTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_azure_ai_search_tool(
|
||||
index_connection_id="conn-1",
|
||||
index_name="my-index",
|
||||
query_type="vector_semantic_hybrid",
|
||||
top_k=5,
|
||||
filter="category eq 'docs'",
|
||||
)
|
||||
assert isinstance(tool_obj, azure_ai_search_tool_cls)
|
||||
indexes = tool_obj.azure_ai_search.indexes
|
||||
assert len(indexes) == 1
|
||||
index = indexes[0]
|
||||
assert index.project_connection_id == "conn-1"
|
||||
assert index.index_name == "my-index"
|
||||
assert index.query_type == "vector_semantic_hybrid"
|
||||
assert index.top_k == 5
|
||||
assert index.filter == "category eq 'docs'"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_sharepoint_tool() -> None:
|
||||
"""SharePoint tool factory wires the connection through nested params."""
|
||||
sharepoint_tool_cls = _skip_if_sdk_class_missing("SharepointPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_sharepoint_tool(connection_id="sp-conn")
|
||||
assert isinstance(tool_obj, sharepoint_tool_cls)
|
||||
connections = tool_obj.sharepoint_grounding_preview.project_connections
|
||||
assert connections is not None
|
||||
assert len(connections) == 1
|
||||
assert connections[0].project_connection_id == "sp-conn"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_fabric_tool() -> None:
|
||||
"""Fabric tool factory wires the connection through nested params."""
|
||||
fabric_tool_cls = _skip_if_sdk_class_missing("MicrosoftFabricPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_fabric_tool(connection_id="fab-conn")
|
||||
assert isinstance(tool_obj, fabric_tool_cls)
|
||||
connections = tool_obj.fabric_dataagent_preview.project_connections
|
||||
assert connections is not None
|
||||
assert len(connections) == 1
|
||||
assert connections[0].project_connection_id == "fab-conn"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_memory_search_tool() -> None:
|
||||
"""Memory search tool factory passes core fields through."""
|
||||
memory_tool_cls = _skip_if_sdk_class_missing("MemorySearchPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_memory_search_tool(
|
||||
memory_store_name="store-1",
|
||||
scope="{{$userId}}",
|
||||
update_delay=600,
|
||||
)
|
||||
assert isinstance(tool_obj, memory_tool_cls)
|
||||
assert tool_obj.memory_store_name == "store-1"
|
||||
assert tool_obj.scope == "{{$userId}}"
|
||||
assert tool_obj.update_delay == 600
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_computer_use_tool() -> None:
|
||||
"""Computer use tool factory passes environment + display dimensions."""
|
||||
computer_use_cls = _skip_if_sdk_class_missing("ComputerUsePreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_computer_use_tool(
|
||||
environment="browser",
|
||||
display_width=1920,
|
||||
display_height=1080,
|
||||
)
|
||||
assert isinstance(tool_obj, computer_use_cls)
|
||||
assert tool_obj.environment == "browser"
|
||||
assert tool_obj.display_width == 1920
|
||||
assert tool_obj.display_height == 1080
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_browser_automation_tool() -> None:
|
||||
"""Browser automation tool factory wraps the connection id in the params type."""
|
||||
browser_tool_cls = _skip_if_sdk_class_missing("BrowserAutomationPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_browser_automation_tool(connection_id="playwright-conn")
|
||||
assert isinstance(tool_obj, browser_tool_cls)
|
||||
assert tool_obj.browser_automation_preview.connection.project_connection_id == "playwright-conn"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_bing_custom_search_tool() -> None:
|
||||
"""Bing custom search tool factory builds the nested search configuration."""
|
||||
bing_tool_cls = _skip_if_sdk_class_missing("BingCustomSearchPreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_bing_custom_search_tool(
|
||||
connection_id="bing-conn",
|
||||
instance_name="my-custom-config",
|
||||
market="en-US",
|
||||
count=10,
|
||||
)
|
||||
assert isinstance(tool_obj, bing_tool_cls)
|
||||
configs = tool_obj.bing_custom_search_preview.search_configurations
|
||||
assert len(configs) == 1
|
||||
config = configs[0]
|
||||
assert config.project_connection_id == "bing-conn"
|
||||
assert config.instance_name == "my-custom-config"
|
||||
assert config.market == "en-US"
|
||||
assert config.count == 10
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_bing_grounding_tool() -> None:
|
||||
"""Bing grounding tool factory builds the nested search configuration."""
|
||||
bing_tool_cls = _skip_if_sdk_class_missing("BingGroundingTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_bing_grounding_tool(
|
||||
connection_id="bing-conn",
|
||||
market="en-US",
|
||||
set_lang="en",
|
||||
count=10,
|
||||
freshness="Day",
|
||||
)
|
||||
assert isinstance(tool_obj, bing_tool_cls)
|
||||
configs = tool_obj.bing_grounding.search_configurations
|
||||
assert len(configs) == 1
|
||||
config = configs[0]
|
||||
assert config.project_connection_id == "bing-conn"
|
||||
assert config.market == "en-US"
|
||||
assert config.set_lang == "en"
|
||||
assert config.count == 10
|
||||
assert config.freshness == "Day"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
def test_get_a2a_tool() -> None:
|
||||
"""A2A tool factory carries base_url, agent_card_path, and project_connection_id."""
|
||||
a2a_tool_cls = _skip_if_sdk_class_missing("A2APreviewTool")
|
||||
|
||||
tool_obj = FoundryChatClient.get_a2a_tool(
|
||||
base_url="https://agent.example.com",
|
||||
agent_card_path="/.well-known/agent-card.json",
|
||||
project_connection_id="a2a-conn",
|
||||
)
|
||||
assert isinstance(tool_obj, a2a_tool_cls)
|
||||
assert tool_obj.base_url == "https://agent.example.com"
|
||||
assert tool_obj.agent_card_path == "/.well-known/agent-card.json"
|
||||
assert tool_obj.project_connection_id == "a2a-conn"
|
||||
|
||||
|
||||
_FOUNDRY_TOOLS_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [
|
||||
("get_azure_ai_search_tool", "AzureAISearchTool", {"index_connection_id": "c", "index_name": "i"}),
|
||||
(
|
||||
"get_bing_grounding_tool",
|
||||
"BingGroundingTool",
|
||||
{"connection_id": "c"},
|
||||
),
|
||||
]
|
||||
|
||||
_FOUNDRY_PREVIEW_TOOLS_FACTORY_CASES: list[tuple[str, str, dict[str, Any]]] = [
|
||||
("get_sharepoint_tool", "SharepointPreviewTool", {"connection_id": "c"}),
|
||||
("get_fabric_tool", "MicrosoftFabricPreviewTool", {"connection_id": "c"}),
|
||||
(
|
||||
"get_memory_search_tool",
|
||||
"MemorySearchPreviewTool",
|
||||
{"memory_store_name": "s", "scope": "u"},
|
||||
),
|
||||
(
|
||||
"get_computer_use_tool",
|
||||
"ComputerUsePreviewTool",
|
||||
{"environment": "browser", "display_width": 1, "display_height": 1},
|
||||
),
|
||||
("get_browser_automation_tool", "BrowserAutomationPreviewTool", {"connection_id": "c"}),
|
||||
(
|
||||
"get_bing_custom_search_tool",
|
||||
"BingCustomSearchPreviewTool",
|
||||
{"connection_id": "c", "instance_name": "i"},
|
||||
),
|
||||
("get_a2a_tool", "A2APreviewTool", {"base_url": "https://a.example.com"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _FOUNDRY_TOOLS_FACTORY_CASES)
|
||||
def test_foundry_tools_factories_are_marked(factory_name: str, sdk_class_name: str, kwargs: dict[str, Any]) -> None:
|
||||
"""Factories wrapping GA Foundry tool SDK classes carry FOUNDRY_TOOLS metadata."""
|
||||
_skip_if_sdk_class_missing(sdk_class_name)
|
||||
factory = getattr(FoundryChatClient, factory_name)
|
||||
assert getattr(factory, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(factory, "__feature_id__", None) == "FOUNDRY_TOOLS"
|
||||
assert factory(**kwargs) is not None
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::FutureWarning")
|
||||
@pytest.mark.parametrize("factory_name, sdk_class_name, kwargs", _FOUNDRY_PREVIEW_TOOLS_FACTORY_CASES)
|
||||
def test_foundry_preview_tools_factories_are_marked(
|
||||
factory_name: str, sdk_class_name: str, kwargs: dict[str, Any]
|
||||
) -> None:
|
||||
"""Factories wrapping preview Foundry tool SDK classes carry FOUNDRY_PREVIEW_TOOLS metadata."""
|
||||
_skip_if_sdk_class_missing(sdk_class_name)
|
||||
factory = getattr(FoundryChatClient, factory_name)
|
||||
assert getattr(factory, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(factory, "__feature_id__", None) == "FOUNDRY_PREVIEW_TOOLS"
|
||||
assert factory(**kwargs) is not None
|
||||
|
||||
|
||||
def test_parse_chunk_surfaces_oauth_consent_request() -> None:
|
||||
"""An oauth_consent_request output item surfaces as Content with consent_link."""
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import threading
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from typing import Protocol, cast
|
||||
|
||||
from agent_framework import (
|
||||
@@ -25,12 +26,14 @@ from agent_framework import (
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
)
|
||||
from agent_framework.exceptions import AgentFrameworkException
|
||||
from azure.ai.agentserver.responses import (
|
||||
ResponseContext,
|
||||
ResponseEventStream,
|
||||
ResponseProviderProtocol,
|
||||
ResponsesServerOptions,
|
||||
)
|
||||
from azure.ai.agentserver.responses._id_generator import IdGenerator
|
||||
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
ApplyPatchToolCallItemParam,
|
||||
@@ -108,11 +111,13 @@ from azure.ai.agentserver.responses.streaming._builders import (
|
||||
ReasoningSummaryPartBuilder,
|
||||
TextContentBuilder,
|
||||
)
|
||||
from mcp import McpError
|
||||
from typing_extensions import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Approval Storage
|
||||
class ApprovalStorage(Protocol):
|
||||
"""Storage for saving function approval requests."""
|
||||
|
||||
@@ -247,6 +252,39 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
|
||||
return FileCheckpointStorage(storage_path)
|
||||
|
||||
|
||||
# endregion Approval Storage
|
||||
|
||||
# Foundry Toolbox Auth integration
|
||||
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
|
||||
CONSENT_ERROR_CODE = -32007
|
||||
|
||||
|
||||
def consent_url_from_error(exc: BaseException) -> str | None:
|
||||
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
|
||||
|
||||
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
|
||||
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException``
|
||||
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a
|
||||
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
|
||||
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
|
||||
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
|
||||
|
||||
Args:
|
||||
exc: The exception to inspect.
|
||||
|
||||
Returns:
|
||||
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
|
||||
"""
|
||||
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
|
||||
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
|
||||
return inner_exception.error.message
|
||||
return None
|
||||
|
||||
|
||||
# endregion Foundry Toolbox Auth integration
|
||||
|
||||
|
||||
# region ResponsesHostServer
|
||||
class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
"""A responses server host for an agent."""
|
||||
|
||||
@@ -315,8 +353,43 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
if self.config.is_hosted
|
||||
else InMemoryFunctionApprovalStorage()
|
||||
)
|
||||
# Lazy agent lifecycle: the agent (and any MCP tools it owns) is entered on
|
||||
# the first request rather than at server startup, so that authentication
|
||||
# failures during MCP connect can be surfaced to the client as an
|
||||
# `oauth_consent_request` stream event instead of crashing the server.
|
||||
self._agent_stack: AsyncExitStack | None = None
|
||||
self._agent_init_lock = asyncio.Lock()
|
||||
self.shutdown_handler(self._cleanup_agent) # pyright: ignore[reportUnknownMemberType]
|
||||
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
async def _ensure_agent_ready(self) -> None:
|
||||
"""Lazily enter the agent's async context exactly once.
|
||||
|
||||
On failure the partial exit stack is closed and ``_agent_stack`` is left
|
||||
as ``None`` so a subsequent request (e.g. after the user completes OAuth
|
||||
consent) can retry the connection.
|
||||
"""
|
||||
if self._agent_stack is not None:
|
||||
return
|
||||
async with self._agent_init_lock:
|
||||
if self._agent_stack is not None:
|
||||
return
|
||||
stack = AsyncExitStack()
|
||||
try:
|
||||
if isinstance(self._agent, AbstractAsyncContextManager):
|
||||
await stack.enter_async_context(self._agent)
|
||||
except BaseException:
|
||||
await stack.aclose()
|
||||
raise
|
||||
self._agent_stack = stack
|
||||
|
||||
async def _cleanup_agent(self) -> None:
|
||||
"""Close the agent's async context. Registered as the server shutdown handler."""
|
||||
stack = self._agent_stack
|
||||
if stack is not None:
|
||||
self._agent_stack = None
|
||||
await stack.aclose()
|
||||
|
||||
async def _handle_response(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
@@ -359,45 +432,76 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
else:
|
||||
run_kwargs["options"] = chat_options
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
|
||||
# Lazy-enter the agent (and any MCP tools it owns). The MCP client wraps gateway
|
||||
# consent failures (and other connection-time errors) in AgentFrameworkException; if
|
||||
# one of those is a consent error we surface the consent link to the client through
|
||||
# the already-opened response stream instead of crashing the request. Other exception
|
||||
# types propagate normally so the host can handle / log them.
|
||||
try:
|
||||
await self._ensure_agent_ready()
|
||||
except AgentFrameworkException as ex:
|
||||
consent_url = consent_url_from_error(ex)
|
||||
if consent_url is None:
|
||||
raise
|
||||
logger.warning("OAuth consent required for Foundry MCP gateway.")
|
||||
oauth_item = OAuthConsentRequestOutputItem(
|
||||
id=IdGenerator.new_id("oacr"),
|
||||
consent_link=consent_url,
|
||||
server_label="Foundry Toolbox",
|
||||
)
|
||||
builder = response_event_stream.add_output_item(oauth_item.id)
|
||||
yield builder.emit_added(oauth_item)
|
||||
yield builder.emit_done(oauth_item)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
# Track the current active output item builder for streaming;
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
tracker: _OutputItemTracker | None = _OutputItemTracker(response_event_stream) if is_streaming_request else None
|
||||
|
||||
# Run the agent in streaming mode
|
||||
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
try:
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
yield response_event_stream.emit_completed()
|
||||
else:
|
||||
if tracker is None: # pragma: no cover - defensive, set above
|
||||
raise RuntimeError("Streaming tracker was not initialized.")
|
||||
# Run the agent in streaming mode
|
||||
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
# Close any remaining active builder
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
# Close any remaining active builder
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
|
||||
yield response_event_stream.emit_completed()
|
||||
yield response_event_stream.emit_completed()
|
||||
except Exception:
|
||||
# Drain any in-progress streaming builder before emitting consent
|
||||
# so the resulting stream stays well-formed.
|
||||
if tracker is not None:
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
yield response_event_stream.emit_completed()
|
||||
raise
|
||||
|
||||
async def _handle_inner_workflow(
|
||||
self,
|
||||
@@ -429,6 +533,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
if not isinstance(self._agent, WorkflowAgent):
|
||||
raise RuntimeError("Agent is not a workflow agent.")
|
||||
|
||||
# Workflow agents are not async context managers in any built-in path,
|
||||
# but call _ensure_agent_ready for symmetry with the regular path so
|
||||
# any future async resources owned by the workflow are entered here.
|
||||
await self._ensure_agent_ready()
|
||||
|
||||
# Determine the latest checkpoint (if any) so we can resume the
|
||||
# workflow's prior state for this turn. The directory is keyed by
|
||||
# the inbound context id (conversation_id when set, otherwise
|
||||
@@ -551,6 +660,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
await checkpoint_storage.delete(checkpoint.checkpoint_id)
|
||||
|
||||
|
||||
# endregion ResponsesHostServer
|
||||
|
||||
# region Active Builder State
|
||||
|
||||
|
||||
|
||||
@@ -27,14 +27,18 @@ from agent_framework import (
|
||||
ResponseStream,
|
||||
)
|
||||
from azure.ai.agentserver.responses import InMemoryResponseProvider
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
from typing_extensions import Any
|
||||
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_foundry_hosting._responses import (
|
||||
CONSENT_ERROR_CODE,
|
||||
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
_output_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
consent_url_from_error,
|
||||
)
|
||||
|
||||
|
||||
@@ -2888,6 +2892,187 @@ class TestCheckpointContextPathValidation:
|
||||
f"before={before} after={after}"
|
||||
)
|
||||
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
|
||||
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
|
||||
|
||||
|
||||
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
|
||||
"""Build an exception wrapping a Foundry MCP gateway consent error.
|
||||
|
||||
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
|
||||
which catches connection-time ``McpError``s and re-raises them as a
|
||||
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
|
||||
original error attached via ``inner_exception``. ``consent_url_from_error``
|
||||
then finds the wrapped ``McpError`` in ``exc.args``.
|
||||
"""
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
|
||||
return ToolExecutionException("MCP consent required", inner_exception=inner)
|
||||
|
||||
|
||||
class TestConsentUrlFromError:
|
||||
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
|
||||
exc = _make_consent_error("https://example.com/consent")
|
||||
assert consent_url_from_error(exc) == "https://example.com/consent"
|
||||
|
||||
def test_returns_none_when_no_mcp_error_in_args(self) -> None:
|
||||
assert consent_url_from_error(Exception("boom")) is None
|
||||
|
||||
def test_returns_none_when_mcp_error_has_different_code(self) -> None:
|
||||
inner = McpError(ErrorData(code=-32000, message="some other error"))
|
||||
exc = Exception("wrapped", inner)
|
||||
assert consent_url_from_error(exc) is None
|
||||
|
||||
def test_returns_none_for_bare_mcp_error_without_wrapping(self) -> None:
|
||||
# `args` of a bare McpError holds the message string, not an McpError
|
||||
# instance, so it does not match the wrapping pattern produced by the
|
||||
# MCP client when it bubbles consent errors up.
|
||||
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
|
||||
assert consent_url_from_error(bare) is None
|
||||
|
||||
|
||||
class TestAgentLifecycle:
|
||||
async def test_agent_entered_lazily_on_first_request(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
# Construction must not enter the agent.
|
||||
assert agent.__aenter__.await_count == 0
|
||||
|
||||
await _post(server, input_text="hello", stream=False)
|
||||
assert agent.__aenter__.await_count == 1
|
||||
|
||||
async def test_agent_entered_only_once_across_requests(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
|
||||
await _post(server, input_text="first", stream=False)
|
||||
await _post(server, input_text="second", stream=False)
|
||||
await _post(server, input_text="third", stream=False)
|
||||
assert agent.__aenter__.await_count == 1
|
||||
|
||||
async def test_cleanup_exits_agent_and_allows_reentry(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
|
||||
await _post(server, input_text="hello", stream=False)
|
||||
assert agent.__aenter__.await_count == 1
|
||||
assert agent.__aexit__.await_count == 0
|
||||
|
||||
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
|
||||
assert agent.__aexit__.await_count == 1
|
||||
|
||||
# Cleanup is idempotent.
|
||||
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
|
||||
assert agent.__aexit__.await_count == 1
|
||||
|
||||
# After cleanup, a follow-up request re-enters the agent.
|
||||
await _post(server, input_text="again", stream=False)
|
||||
assert agent.__aenter__.await_count == 2
|
||||
|
||||
async def test_failed_entry_does_not_cache_stack(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = [_make_consent_error(), None]
|
||||
server = _make_server(agent)
|
||||
|
||||
await _post(server, input_text="first", stream=False)
|
||||
# Failed entry must leave the stack empty so the next request retries.
|
||||
await _post(server, input_text="second", stream=False)
|
||||
assert agent.__aenter__.await_count == 2
|
||||
|
||||
|
||||
class TestOAuthConsentSurfacing:
|
||||
async def test_non_streaming_consent_error_emits_oauth_output_item(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post(server, input_text="hello", stream=False)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
oauth_items = [it for it in body["output"] if it["type"] == "oauth_consent_request"]
|
||||
assert len(oauth_items) == 1
|
||||
assert oauth_items[0]["consent_link"] == "https://consent.example.com/auth"
|
||||
assert oauth_items[0]["server_label"] == "Foundry Toolbox"
|
||||
|
||||
# The agent must not be run when entry fails.
|
||||
agent.run.assert_not_called()
|
||||
|
||||
async def test_streaming_consent_error_emits_oauth_output_item(self) -> None:
|
||||
agent = _make_agent(stream_updates=[AgentResponseUpdate(contents=[Content.from_text("hi")], role="assistant")])
|
||||
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post(server, input_text="hello", stream=True)
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[1] == "response.in_progress"
|
||||
assert types[-1] == "response.completed"
|
||||
|
||||
added = [e for e in events if e["event"] == "response.output_item.added"]
|
||||
oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"]
|
||||
assert len(oauth_added) == 1
|
||||
assert oauth_added[0]["data"]["item"]["consent_link"] == "https://consent.example.com/auth"
|
||||
assert oauth_added[0]["data"]["item"]["server_label"] == "Foundry Toolbox"
|
||||
|
||||
done = [e for e in events if e["event"] == "response.output_item.done"]
|
||||
assert any(e["data"]["item"]["type"] == "oauth_consent_request" for e in done)
|
||||
|
||||
agent.run.assert_not_called()
|
||||
|
||||
async def test_non_consent_error_during_entry_propagates(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = RuntimeError("boom")
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post(server, input_text="hello", stream=False)
|
||||
# Non-consent errors are not swallowed: the response is marked failed
|
||||
# and no `oauth_consent_request` item is emitted.
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "failed"
|
||||
assert not any(it["type"] == "oauth_consent_request" for it in body.get("output", []))
|
||||
agent.run.assert_not_called()
|
||||
|
||||
async def test_retry_after_consent_succeeds(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hello!")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = [_make_consent_error("https://consent.example.com/auth"), None]
|
||||
server = _make_server(agent)
|
||||
|
||||
# First request surfaces consent; agent.run is not called.
|
||||
resp1 = await _post(server, input_text="first", stream=False)
|
||||
assert resp1.status_code == 200
|
||||
body1 = resp1.json()
|
||||
oauth = [it for it in body1["output"] if it["type"] == "oauth_consent_request"]
|
||||
assert len(oauth) == 1
|
||||
agent.run.assert_not_called()
|
||||
|
||||
# After the user authenticates, the next request enters successfully.
|
||||
resp2 = await _post(server, input_text="second", stream=False)
|
||||
assert resp2.status_code == 200
|
||||
body2 = resp2.json()
|
||||
assert body2["status"] == "completed"
|
||||
assert any(it["type"] == "message" for it in body2["output"])
|
||||
assert agent.__aenter__.await_count == 2
|
||||
agent.run.assert_awaited_once()
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Monty Package (agent-framework-monty)
|
||||
|
||||
Monty-backed CodeAct integrations for the Microsoft Agent Framework.
|
||||
|
||||
> [!NOTE]
|
||||
> **Alpha package.** Not part of `agent-framework[all]` yet. Install explicitly
|
||||
> with `pip install agent-framework-monty --pre`.
|
||||
|
||||
## Core Classes
|
||||
|
||||
- **`MontyCodeActProvider`** — `ContextProvider` that injects a run-scoped
|
||||
`execute_code` tool plus dynamic CodeAct instructions. Mirrors the
|
||||
`HyperlightCodeActProvider` API for the parts that apply to a non-sandboxed
|
||||
Python interpreter.
|
||||
- **`MontyExecuteCodeTool`** — `FunctionTool` that wraps the Monty interpreter.
|
||||
Use directly for mixed-tool agents or manual static wiring. Mirrors
|
||||
`HyperlightExecuteCodeTool`.
|
||||
|
||||
## Public API
|
||||
|
||||
```python
|
||||
from agent_framework_monty import (
|
||||
FileMount,
|
||||
FileMountInput,
|
||||
MontyCodeActProvider,
|
||||
MontyExecuteCodeTool,
|
||||
MountMode,
|
||||
)
|
||||
```
|
||||
|
||||
`MontyCodeActProvider` and `MontyExecuteCodeTool` both accept:
|
||||
- `tools` — host tool callables / `FunctionTool`s
|
||||
- `approval_mode` — `"never_require"` (default) or `"always_require"`
|
||||
- `workspace_root` — host directory auto-mounted at `/input`
|
||||
(mirrors `HyperlightCodeActProvider.workspace_root`)
|
||||
- `file_mounts` — sequence of `FileMountInput` (str shorthand,
|
||||
`(host_path, mount_path)` tuple, or `FileMount`)
|
||||
- `resource_limits` — Monty `ResourceLimits` TypedDict
|
||||
|
||||
Tool-management methods on both classes: `add_tools`, `get_tools`,
|
||||
`remove_tool`, `clear_tools`. Mount-management methods: `add_file_mounts`,
|
||||
`get_file_mounts`, `remove_file_mount`, `clear_file_mounts`.
|
||||
|
||||
`MontyExecuteCodeTool` additionally exposes:
|
||||
- `build_instructions(*, tools_visible_to_model: bool) -> str`
|
||||
- `create_run_tool() -> MontyExecuteCodeTool`
|
||||
- `build_serializable_state() -> dict[str, Any]`
|
||||
- `workspace_root`, `resource_limits` properties
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`_types.py`** — `FileMount`, `FileMountInput`, `MountMode` (public).
|
||||
- **`_provider.py`** — `MontyCodeActProvider` (thin wrapper around the tool).
|
||||
- **`_execute_code_tool.py`** — `MontyExecuteCodeTool` plus tool / mount
|
||||
normalization, approval helpers, dynamic `description`/`instructions`
|
||||
builders, and the post-execution file-capture flow that surfaces files
|
||||
written to `read-write` mounts as `Content.from_data` items.
|
||||
- **`_monty_bridge.py`** — `InlineCodeBridge` and `generate_type_stubs`,
|
||||
adapted from the reference Monty CodeAct repo. Pauses on `FunctionSnapshot`
|
||||
to dispatch host calls, then resumes; supports direct typed tool calls,
|
||||
the `call_tool` fallback, `asyncio.gather` fan-out, and forwards
|
||||
``mount`` / ``limits`` to `Monty(...).start(...)`.
|
||||
- **`_instructions.py`** — dynamic instruction / tool-description builders
|
||||
(include filesystem capability summaries when mounts are configured).
|
||||
|
||||
## Not implemented (yet)
|
||||
|
||||
| Capability | Monty primitive | Status |
|
||||
|------------|-----------------|--------|
|
||||
| Custom virtual filesystem | `OSAccess` subclass passed to `Monty(...).start(os=...)` | Not exposed. Strictly more general than file mounts; useful when you want a fully synthetic FS. |
|
||||
| Outbound URL allow-list | No Monty primitive — expose `fetch_url` as a host tool with the allow-list check in your tool function. | Not exposed in this package; users add it as a regular tool. |
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- **Durable execution** — the reference Monty CodeAct repo also offers a
|
||||
Durable-Functions-backed mode (`DurableCodeBridge`, `register_durable_codeact`,
|
||||
`wait_for_external_event`, per-tool approval via external events). That is
|
||||
intentionally not in this package yet.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,179 @@
|
||||
# agent-framework-monty
|
||||
|
||||
Monty-backed CodeAct integrations for Microsoft Agent Framework.
|
||||
|
||||
> [!WARNING]
|
||||
> This package is in **alpha**. APIs may change without notice. It is not part of
|
||||
> `agent-framework[all]` yet; install it explicitly with `--pre`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install agent-framework-monty --pre
|
||||
```
|
||||
|
||||
The package depends on [`pydantic-monty`](https://github.com/pydantic/monty), a
|
||||
Rust-based Python interpreter, so it runs on Linux, macOS, and Windows wherever
|
||||
Monty wheels are published — no hypervisor or WASM backend required.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Context provider (recommended)
|
||||
|
||||
Use `MontyCodeActProvider` to automatically inject the `execute_code` tool and
|
||||
CodeAct instructions into every agent run. Tools registered on the provider are
|
||||
available inside the Monty interpreter as **typed async functions** (e.g.
|
||||
`await compute(operation="add", a=1, b=2)`), and as a fallback through
|
||||
`call_tool(...)`.
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework_monty import MontyCodeActProvider
|
||||
|
||||
|
||||
@tool
|
||||
def compute(operation: str, a: float, b: float) -> float:
|
||||
"""Perform a math operation."""
|
||||
ops = {"add": a + b, "subtract": a - b, "multiply": a * b, "divide": a / b}
|
||||
return ops[operation]
|
||||
|
||||
|
||||
codeact = MontyCodeActProvider(
|
||||
tools=[compute],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="CodeActAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
context_providers=[codeact],
|
||||
)
|
||||
|
||||
result = await agent.run("Multiply 6 by 7 using execute_code.")
|
||||
```
|
||||
|
||||
### Standalone tool
|
||||
|
||||
Use `MontyExecuteCodeTool` directly when you want full control over how the
|
||||
tool is added to the agent (e.g. when mixing sandbox tools with direct-only
|
||||
tools on the same agent).
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework_monty import MontyExecuteCodeTool
|
||||
|
||||
|
||||
@tool
|
||||
def send_email(to: str, subject: str, body: str) -> str:
|
||||
"""Send an email (direct-only, not available inside the sandbox)."""
|
||||
return f"Email sent to {to}"
|
||||
|
||||
|
||||
execute_code = MontyExecuteCodeTool(
|
||||
tools=[compute],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="MixedToolsAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
tools=[send_email, execute_code],
|
||||
)
|
||||
```
|
||||
|
||||
### Manual static wiring
|
||||
|
||||
For fixed configurations where provider lifecycle overhead is unnecessary,
|
||||
build the CodeAct instructions once and pass them to the agent at construction
|
||||
time:
|
||||
|
||||
```python
|
||||
execute_code = MontyExecuteCodeTool(
|
||||
tools=[compute],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="StaticWiringAgent",
|
||||
instructions=f"You are a helpful assistant.\n\n{codeact_instructions}",
|
||||
tools=[execute_code],
|
||||
)
|
||||
```
|
||||
|
||||
### File mounts and resource limits
|
||||
|
||||
Mount host directories into the sandbox and cap execution resources:
|
||||
|
||||
```python
|
||||
from agent_framework_monty import FileMount, MontyCodeActProvider
|
||||
|
||||
codeact = MontyCodeActProvider(
|
||||
tools=[compute],
|
||||
workspace_root="/host/workspace", # auto-mounted at /input (read-write)
|
||||
file_mounts=[
|
||||
"/host/data", # shorthand: same path on both sides
|
||||
("/host/models", "/sandbox/models"), # explicit (host, mount_path)
|
||||
FileMount( # full control
|
||||
host_path="/host/cache",
|
||||
mount_path="/sandbox/cache",
|
||||
mode="overlay", # "read-only" | "read-write" | "overlay"
|
||||
write_bytes_limit=10 * 1024 * 1024,
|
||||
),
|
||||
],
|
||||
resource_limits={ # Monty ResourceLimits TypedDict
|
||||
"max_duration_secs": 5.0,
|
||||
"max_memory": 64 * 1024 * 1024,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- **`workspace_root`** mirrors the Hyperlight default: the directory is mounted
|
||||
at `/input` in `read-write` mode.
|
||||
- **`file_mounts`** accepts a string shorthand, a `(host_path, mount_path)`
|
||||
tuple, or a `FileMount` named tuple (with optional `mode` and
|
||||
`write_bytes_limit`).
|
||||
- Files written by the sandbox to any **`read-write`** mount are scanned
|
||||
after each `execute_code` call and returned as `Content.from_data(...)`
|
||||
attachments (with a `path` annotation in `additional_properties`),
|
||||
mirroring Hyperlight's `/output` flow.
|
||||
- `overlay` mounts buffer writes in memory (nothing leaks to the host and
|
||||
nothing is captured). `read-only` mounts reject writes.
|
||||
- **`resource_limits`** is forwarded straight to Monty's
|
||||
[`ResourceLimits`](https://github.com/pydantic/monty) TypedDict
|
||||
(`max_allocations`, `max_duration_secs`, `max_memory`, `gc_interval`,
|
||||
`max_recursion_depth`).
|
||||
|
||||
## DSL inside `execute_code`
|
||||
|
||||
The model generates Python code that runs inside Monty's Rust-based interpreter.
|
||||
Available primitives:
|
||||
|
||||
| Primitive | Behavior |
|
||||
|-----------|----------|
|
||||
| `await tool_name(**kwargs)` | Direct typed call to a registered host tool. Argument types are checked before execution. |
|
||||
| `await call_tool("name", **kwargs)` | Generic fallback that dispatches by tool name. Not type-checked. |
|
||||
| `asyncio.gather(...)` | Fans out concurrent tool calls. |
|
||||
| `print(...)` | Captured and surfaced as text in the tool result. |
|
||||
|
||||
## Notes
|
||||
|
||||
- `MontyCodeActProvider` and `MontyExecuteCodeTool` mirror the API surface of
|
||||
the `agent-framework-hyperlight` counterparts where the underlying runtime
|
||||
supports it.
|
||||
- Monty interprets a **subset** of Python (a Rust-based interpreter). Most
|
||||
control flow, common stdlib modules (`sys`, `os`, `typing`, `asyncio`, `re`,
|
||||
`datetime`, `json`), and async functions are supported, but exotic features
|
||||
may not be available. OS-level access (filesystem, network, subprocess) is
|
||||
rejected with `PermissionError` **by default**; mount host directories with
|
||||
`workspace_root` / `file_mounts` to grant scoped filesystem access.
|
||||
- Code is type-checked against tool signatures via
|
||||
[ty](https://docs.astral.sh/ty/) before execution, so wrong argument types
|
||||
surface as a clear error before any host tool runs.
|
||||
- The alpha package is **not** part of `agent-framework[all]` yet, so it must
|
||||
be installed explicitly. Once promoted to beta it will be reachable via the
|
||||
lazy-loading namespace `agent_framework.monty`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._execute_code_tool import MontyExecuteCodeTool
|
||||
from ._provider import MontyCodeActProvider
|
||||
from ._types import FileMount, FileMountInput, MountMode
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"FileMount",
|
||||
"FileMountInput",
|
||||
"MontyCodeActProvider",
|
||||
"MontyExecuteCodeTool",
|
||||
"MountMode",
|
||||
"__version__",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user