Compare commits

..
264 changed files with 4283 additions and 20295 deletions
+3 -9
View File
@@ -17,7 +17,7 @@ runs:
using: "composite"
steps:
- name: Set up uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
uses: astral-sh/setup-uv@v6
with:
version-file: "python/pyproject.toml"
enable-cache: true
@@ -32,13 +32,7 @@ runs:
if grep -q "name = \"$pkg\"" "$f"; then
pkg_dir=$(dirname "$f" | sed 's|python/||')
echo "Excluding workspace package: $pkg ($pkg_dir)"
if awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/{found=1} END{exit !found}' python/pyproject.toml; then
if ! awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/ && index($0, "\"'"$pkg_dir"'\"")' python/pyproject.toml | grep -q .; then
sed -i.bak '/\[tool\.uv\.workspace\]/,/^\[/ { /^exclude = \[/ s|\]|, "'"$pkg_dir"'"]| }' python/pyproject.toml
fi
else
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
fi
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
fi
done
@@ -46,4 +40,4 @@ runs:
- name: Install the project
shell: bash
run: |
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
cd python && uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
@@ -24,7 +24,7 @@ runs:
using: "composite"
steps:
- name: Set up Node.js environment
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
+3 -9
View File
@@ -44,15 +44,9 @@ updates:
# Maintain dependencies for github-actions
- package-ecosystem: "github-actions"
# 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/*"
# Workflow files stored in the
# default location of `.github/workflows`
directory: "/"
schedule:
interval: "weekly"
day: "sunday"
+4 -4
View File
@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/init@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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/autobuild@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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+5 -5
View File
@@ -66,7 +66,7 @@ jobs:
- name: Check PR author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@v7
with:
version: "0.11.x"
enable-cache: true
+24 -24
View File
@@ -41,8 +41,8 @@ jobs:
functionsChanged: ${{ steps.filter.outputs.functions }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -111,7 +111,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -122,7 +122,7 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@2a82782178b2816d9d6960a7345fdd164791b323 # 5.5.3
uses: danielpalme/ReportGenerator-GitHub-Action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -366,7 +366,7 @@ jobs:
python
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -453,7 +453,7 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
@@ -585,7 +585,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@v4
with:
pattern: dotnet-test-results-*
path: dotnet-test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: dotnet-integration-test-report
path: |
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@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@b17fbb00bdc0c0f63fcf166580804b4d2cdc2a42 # v1
uses: jitterbit/get-changed-files@v1
continue-on-error: true
- name: No C# files changed
@@ -29,7 +29,7 @@ jobs:
environment: integration
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+4 -4
View File
@@ -41,7 +41,7 @@ jobs:
environment: 'integration'
timeout-minutes: 90
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -52,13 +52,13 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: verify-samples-results
path: |
+7 -8
View File
@@ -53,7 +53,7 @@ jobs:
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
@@ -101,7 +101,7 @@ jobs:
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -137,7 +137,6 @@ jobs:
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
+1 -1
View File
@@ -16,6 +16,6 @@ jobs:
pull-requests: write
steps:
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
- uses: actions/labeler@v6
with:
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- uses: actions/github-script@v8
name: "Issue/PR: update title"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -19,13 +19,13 @@ jobs:
runs-on: ubuntu-22.04
# check out the latest version of the code
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
# Checks the status of hyperlinks in all files
- name: Run linkspector
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
uses: umbrelladocs/action-linkspector@v1
with:
reporter: local
filter_mode: nofilter
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Wait for required checks
if: github.event_name == 'pull_request'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
env:
TIMEOUT_SECONDS: "3600"
INTERVAL_SECONDS: "30"
+6 -6
View File
@@ -27,7 +27,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
- uses: actions/cache@v5
with:
path: ~/.cache/prek
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1
- uses: j178/prek-action@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
+2 -2
View File
@@ -24,9 +24,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@v7
with:
version-file: "python/pyproject.toml"
enable-cache: true
+27 -27
View File
@@ -36,7 +36,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -69,7 +69,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
@@ -112,7 +112,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
@@ -163,7 +163,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
@@ -283,7 +283,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
@@ -341,7 +341,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
@@ -388,7 +388,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry-hosting
path: ./python/pytest.xml
@@ -443,7 +443,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
@@ -496,7 +496,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
+4 -4
View File
@@ -24,8 +24,8 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -59,7 +59,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/packages/lab/**.xml
summary: true
+37 -37
View File
@@ -41,8 +41,8 @@ jobs:
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -106,7 +106,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -153,7 +153,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
@@ -214,7 +214,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
@@ -284,7 +284,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
@@ -417,7 +417,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
@@ -488,7 +488,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
@@ -549,7 +549,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry-hosting
path: ./python/pytest.xml
@@ -620,7 +620,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
@@ -680,19 +680,19 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@v2
with:
files: |
python/dist/*
+37 -37
View File
@@ -29,7 +29,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-01-get-started
@@ -82,7 +82,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents
@@ -130,7 +130,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-openai
@@ -170,7 +170,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-azure
@@ -208,7 +208,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-anthropic
@@ -242,7 +242,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-github-copilot
@@ -274,7 +274,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-amazon
@@ -306,7 +306,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-ollama
@@ -341,7 +341,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-foundry
@@ -383,7 +383,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-copilotstudio
@@ -419,7 +419,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-custom
@@ -451,7 +451,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-03-workflows
@@ -491,7 +491,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-04-hosting
@@ -534,7 +534,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-05-end-to-end
@@ -574,7 +574,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-autogen-migration
@@ -633,7 +633,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Download all validation reports
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v7
with:
pattern: validation-report-*
path: reports/
@@ -701,7 +701,7 @@ jobs:
- name: Restore validation history
id: cache-restore
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@v4
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
- name: Upload trend report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-trend-report
@@ -19,9 +19,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Download coverage report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
uses: actions/download-artifact@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@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
uses: MishaKav/pytest-coverage-comment@v1.6.0
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
issue-number: ${{ env.PR_NUMBER }}
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
env:
UV_PYTHON: "3.11"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
path: |
python/python-coverage.xml
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@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@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
+2 -2
View File
@@ -31,9 +31,9 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@v5
with:
python-version: '3.13'
@@ -0,0 +1,173 @@
# Issue #5350 — Root-cause validation plan
**Issue:** [`.NET: [Bug]: Checkpoint round-trip loses ToolApprovalRequestContent.ToolCall concrete type (FunctionCallContent → base ToolCallContent), breaking FICC approval resume`](https://github.com/microsoft/agent-framework/issues/5350)
**Status of repro:** A focused repro test class
`dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ToolApprovalRequestCheckpointReproTests.cs`
was added covering seven progressively more end-to-end variants of the path the issue
describes — including a **maximal** end-to-end test that uses a real
`ChatClientAgent` driven by `FunctionInvokingChatClient` with an
`ApprovalRequiredAIFunction`. **All seven tests pass** on `main`, consistently across
5 back-to-back runs, i.e. *the bug as described does not reproduce* at any of the
layers exercised here:
| # | Test | What it exercises | Result |
|---|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
| 1 | `Repro_5350_ToolApprovalRequestContent_DirectJsonMarshallerRoundtrip_PreservesFunctionCallContent` | `JsonMarshaller.Marshal/Marshal<T>` on `ToolApprovalRequestContent` directly (same options chain as `CheckpointManager.CreateJson`) | Pass |
| 2 | `Repro_5350_ToolApprovalRequestContent_WrappedInPortableValue_PreservesFunctionCallContent` | Same, wrapped in a `PortableValue` (as `PortableMessageEnvelope` would store it) | Pass |
| 3 | `Repro_5350_ToolApprovalRequestContent_AsExternalRequestData_PreservesFunctionCallContent` | Same, as the `Data` payload of an `ExternalRequest` | Pass |
| 4 | `Repro_5350_DirectJsonMarshallerRoundtrip_IsDeterministic` | 25× repetition of #1 to rule out flakiness / JIT order | Pass |
| 5 | `Repro_5350_CaptureWireFormat_ForInspection` | Captures the on-the-wire shape so we can compare against the OP's SQL row | Pass |
| 6 | `Repro_5350_EndToEnd_JsonCheckpointResume_PreservesFunctionCallContentAsync` | Full `CheckpointManager.CreateJson(InMemoryJsonStore) → RunStreamingAsync → SuperStep checkpoint → ResumeStreamingAsync` cycle with a `RequestPort<TARC, TARR>` | Pass |
| 7 | `Repro_5350_EndToEnd_ChatClientAgent_WithApprovalRequiredTool_JsonCheckpointResume_PreservesFunctionCallContentAndInvokesToolAsync` | **Maximal**: `ChatClientAgent` over a `MockChatClient` with an `ApprovalRequiredAIFunction`, single-agent `WorkflowBuilder` (no orchestration), `CheckpointManager.CreateJson(InMemoryJsonStore)`. Asserts both that the resumed `RequestInfoEvent.Request` still carries a `FunctionCallContent` AND that approving the request actually invokes the underlying `AIFunction` and lets the workflow continue. | Pass |
This is **consistent with [@lokitoth's second comment](https://github.com/microsoft/agent-framework/issues/5350#issuecomment-4379664401)**:
> Looking at the MEAI types, it does have `[JsonDerivedType(typeof(FunctionCallContent), "functionCall")]` set on it, and has had it that way for some time, so it is unlikely to be the root cause. […] `JsonMarshaller` […] takes an optional `JsonSerializationOptions` provided by the user […] but this is used only if the internal one (via `WorkflowsJsonUtilities`, which chains to `AgentAbstractionsJsonUtilities`, which then chains to the `AIJsonUtilities` class) [is missing the type].
Inspection of the actual wire format produced by `JsonMarshaller` for a
`ToolApprovalRequestContent` containing a `FunctionCallContent` confirms the
discriminator is emitted:
```jsonc
// TARC at top level
{
"toolCall": {
"$type": "functionCall", // ← polymorphism discriminator present
"name": "DoTheThing",
"arguments": { "x": 42 },
"informationalOnly": false,
"callId": "call-1"
},
"requestId": "req-1"
}
// TARC inside a PortableValue (= shape stored by PortableMessageEnvelope)
{
"typeId": {
"assemblyName": "Microsoft.Extensions.AI.Abstractions, Version=10.5.0.0, …",
"typeName": "Microsoft.Extensions.AI.ToolApprovalRequestContent"
},
"value": {
"toolCall": {
"$type": "functionCall",
},
"requestId": "req-1"
}
}
```
So the OP's stated root-cause hypothesis — *"`AIContent`/`ToolCallContent`/
`FunctionCallContent` are missing `[JsonPolymorphic]`/`[JsonDerivedType]`, or
`CheckpointManager.CreateJson` does not pull from `AIJsonUtilities.DefaultOptions`"*
— is **not** what is producing the failure described in the issue. The annotations
exist, the resolver chain wires them through, and the discriminator does survive
both the direct `JsonMarshaller` round-trip and a real `Run → checkpoint → Resume`
cycle for a `RequestPort` whose request type is `ToolApprovalRequestContent`.
The remaining sections lay out, in priority order, the work needed to identify
the *actual* root cause. The plan deliberately keeps the failing repro from
above as the baseline ("this is what works") and walks outward from it toward
the OP's reported scenario, varying one dimension at a time.
## Plan
### Track A — Reproduce in a configuration closer to the OP's pattern "B"
The OP's repro path differs from the new tests in three concrete ways. Tests #7
(maximal repro) and #8#10 (A2 / A3 / A4 below) close all four gaps. The OP's
specific hypothesis (`TARC.ToolCall is not FunctionCallContent` after resume) does
not reproduce in any of them; A2 did however uncover a *separate, unrelated* bug.
| Step | Variable that changes vs. the passing tests in this repo | Why it matters | Outcome |
|------|--------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| A1 | ~~Use `ChatClientAgent` + `ApprovalRequiredAIFunction` bound into a `WorkflowBuilder`~~**covered by test #7 in this PR; passes.** | Was the largest gap to the OP's repro. Closed. | OP hypothesis disproved — `TARC.ToolCall is FunctionCallContent` post-resume, and the tool is actually invoked exactly once when the approval response is sent. |
| A2 | Multi-agent variant: same as #7 but with the agent inside a `GroupChatBuilder` (the OP's actual orchestration) with `RoundRobinGroupChatManager`. | If group chat re-encodes TARC as part of `ChatMessage.Contents` (`AIContent` polymorphism is two-deep through `ToolApprovalRequestContent`), one branch may resolve and the other not. | OP hypothesis disproved — `TARC.ToolCall is FunctionCallContent` post-resume. **But:** sending the approval response surfaces a *different* real bug — `FunctionInvokingChatClient.ExtractAndRemoveApprovalRequestsAndResponses` throws `ArgumentException: An item with the same key has already been added. Key: ficc_call-1`. Pinned by test as documented misbehavior. |
| A2b | Same as A2 but using `HandoffWorkflowBuilder` (initial agent has the approval tool, a no-op peer agent makes the handoff graph valid; the mock chat client never emits a `handoff_to_*` call). | If the duplicate-key bug from A2 is broader than `RoundRobinGroupChatManager` and lives in the shared `AIAgentHostExecutor` / `ChatProtocolExecutor` path, the handoff workflow should hit it too. | OP hypothesis disproved — `TARC.ToolCall is FunctionCallContent` post-resume **and** the workflow completes cleanly: tool invoked exactly once, zero errors, zero executor failures. The duplicate-key bug from A2 does **not** occur on the handoff path, narrowing it to the group-chat-specific orchestration. |
| A3 | Same as #7 but with the checkpoint `JsonElement` round-tripped through `string` + `JsonDocument.Parse` between commit and retrieve (`StringRoundTripJsonStore`), emulating the SQL `nvarchar` hop in the OP's Dapper store. | The OP uses Dapper + SQL Server. If the column / driver round-trip preserves ordering, this should be identity-preserving — but if it reorders metadata properties, the `$type` discriminator can be moved out of first position, which then requires `AllowOutOfOrderMetadataProperties = true`. | OP hypothesis disproved — byte-preserving string round-trip is identity-preserving for the relevant payload; `TARC.ToolCall is FunctionCallContent` post-resume; tool invoked exactly once. The OP's storage layer would have to *perturb* the JSON (e.g. reorder metadata) for this to reproduce. |
| A4 | Same as #7 but with non-default `JsonSerializerOptions` (`JsonSerializerDefaults.Web`, no AIJsonUtilities resolver) passed as `customOptions` to `CheckpointManager.CreateJson`. | `JsonMarshaller.LookupTypeInfo` only goes to the external options when the internal chain doesn't know about the type. For most cases this won't trigger, but it's worth confirming that supplying a custom `JsonSerializerOptions` does not silently displace the internal chain. | OP hypothesis disproved — custom external options that DO NOT know about `AIContent` types are correctly ignored for known types; the internal `WorkflowsJsonUtilities.DefaultOptions` chain wins. `TARC.ToolCall is FunctionCallContent` post-resume; tool invoked exactly once. |
### Track B — Validate the wire format the OP actually persists
Once Track A has reproduced (or has clearly failed to reproduce) the symptom,
ask the OP for one of the following, in order of preference:
1. The raw `JsonElement.GetRawText()` they pass to their SQL store on commit,
and the raw string they read back on `RetrieveCheckpointAsync` — for the
exact checkpoint that contains the failing `ToolApprovalRequestContent`.
This tells us in a single round-trip whether:
- the `$type` discriminator is present on commit (rules out write-side bug),
- the `$type` discriminator survives the SQL round-trip (rules out store bug),
- the discriminator is in metadata-first position (rules out the
`AllowOutOfOrderMetadataProperties` story).
2. A repro PR/gist that runs against `CheckpointManager.CreateJson(...)` with
an in-memory `JsonCheckpointStore` shim that mirrors how their SQL subclass
passes bytes through. The OP already offered this in the issue body
(*"I can produce a minimal standalone repro against a fake `IChatClient` if useful — let me know."*) — taking them up on it short-circuits a lot of guessing.
### Track C — Defense-in-depth fixes worth landing regardless of root cause
These are addressed in the OP's "Asks" section (asks 24) and are useful
documentation/ergonomics improvements even if the root cause turns out to be in
the OP's store implementation:
1. **Doc-only**: in the XML docs for `CheckpointManager.CreateJson` and on the
`JsonCheckpointStore` base class, explicitly state that the internal options
chain through `WorkflowsJsonUtilities → AgentAbstractionsJsonUtilities →
AIJsonUtilities`, and that user-supplied `customOptions` are consulted only
when the internal chain has no `TypeInfo` for a requested type.
2. **Doc-only**: document the contract that any user `JsonCheckpointStore`
subclass MUST preserve the exact byte sequence of the `JsonElement` it is
given. If the underlying store reorders JSON metadata, the consumer must opt
in by setting `AllowOutOfOrderMetadataProperties = true` on a
`JsonSerializerOptions` passed as `customOptions` to
`CheckpointManager.CreateJson(...)`.
3. **Optional, only if Track A reproduces**: add a regression test mirroring
the failing path under `dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests`
so the fix is locked in.
### Track D — Reject the OP's hypothesis (if not already done)
The combination of:
- the polymorphism annotations existing in `Microsoft.Extensions.AI` for some time
(per @lokitoth),
- the resolver chain in `WorkflowsJsonUtilities.CreateDefaultOptions()` already
putting `AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver` first
(which itself puts `AIJsonUtilities.DefaultOptions.TypeInfoResolver` first),
- the wire format captured above showing `"$type": "functionCall"` is present,
- the full `Run → checkpoint → Resume` test in this PR passing for a plain
`RequestPort<TARC, ToolApprovalResponseContent>` workflow, **and**
- the maximal `ChatClientAgent` + `FunctionInvokingChatClient` +
`ApprovalRequiredAIFunction` test in this PR also passing — including the
assertion that the wrapped `AIFunction` is actually invoked exactly once after
approval and that the workflow then receives the resulting
`FunctionResultContent` and produces a final assistant message,
is sufficient to **disprove** the OP's stated hypothesis. Once Track A or
Track B identifies the actual cause, the issue should be updated with a brief
explanation of why the original guess was incorrect, so future readers don't
re-tread it.
## Pointers for the next investigator
- Repro tests:
`dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ToolApprovalRequestCheckpointReproTests.cs`
- Marshaller under test:
`dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs`
- Where `CheckpointManager.CreateJson` enters that path:
`dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs`
- Options chain (TARC ⇒ resolver):
`dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs`
`dotnet/src/Microsoft.Agents.AI.Abstractions/AgentAbstractionsJsonUtilities.cs`
`Microsoft.Extensions.AI.AIJsonUtilities.DefaultOptions`
- `PortableValue`-side serialization (this is where `value.Value.GetType()` is
used on write, which is the most plausible *internal* place a polymorphism
bug could hide, but the wire capture above shows it is not the cause in the
TARC-as-RequestPort scenario):
`dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/PortableValueConverter.cs`
- Likely next code to inspect for Track A1/A2 (agent-host serialization path):
`dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostExecutor*.cs` and
whatever turns the FICC-generated TARC into something
`AIAgentHostExecutor` stores in its state bag.
-1
View File
@@ -112,7 +112,6 @@
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
<!-- Inference SDKs -->
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
-6
View File
@@ -121,11 +121,9 @@
<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" />
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
</Folder>
@@ -212,7 +210,6 @@
</Folder>
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
@@ -282,7 +279,6 @@
</Folder>
<Folder Name="/Samples/03-workflows/Orchestration/">
<Project Path="samples/03-workflows/Orchestration/Handoff/Handoff.csproj" />
<Project Path="samples/03-workflows/Orchestration/Magentic/Magentic.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Observability/">
<Project Path="samples/03-workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
@@ -603,7 +599,6 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
@@ -657,7 +652,6 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
@@ -1151,25 +1151,6 @@ internal static class AgentsSamples
SkipReason = "Runs as an MCP stdio server that does not exit on its own.",
},
new SampleDefinition
{
Name = "Agent_MCP_LongRunningTask_Client",
ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client",
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
MustContain =
[
"=== Transparent long-running MCP task (RunAsync) ===",
"=== Transparent long-running MCP task (RunStreamingAsync) ===",
],
ExpectedOutputDescription =
[
"The output should show an agent analyzing a dataset named 'sales-2025-q1' and producing a summary mentioning rows, revenue, anomalies, or outliers.",
"The output should contain both a non-streaming response (after RunAsync) and a streaming response (after RunStreamingAsync) for the same analysis question.",
"The output should not contain error messages or stack traces.",
],
},
new SampleDefinition
{
Name = "AGUI_Step01_GettingStarted_Client",
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.6.2</VersionPrefix>
<VersionPrefix>1.6.1</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260521</DateSuffix>
<DateSuffix>260514</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.6.2</GitTag>
<GitTag>1.6.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -24,11 +24,6 @@ public static class AnsiEscapes
/// </summary>
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
/// <summary>
/// Erases the current line from the cursor position to the end of the line (EL 0).
/// </summary>
public static string EraseToEndOfLine => "\x1b[0K";
/// <summary>
/// Erases the entire current line (EL 2).
/// </summary>
@@ -39,7 +39,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
{
foreach (string line in props.Title.Split('\n'))
{
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(line);
row++;
@@ -51,7 +51,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
for (int i = 0; i < totalItems; i++)
{
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
bool isSelected = i == props.SelectedIndex;
@@ -58,11 +58,11 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
{
int promptLength = props.Prompt.Length;
int textWidth = props.Width - promptLength;
int textWidth = this.Width - promptLength;
string indent = new(' ', promptLength);
// First line: prompt + start of text
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(props.Prompt);
@@ -90,7 +90,7 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
while (offset < props.Text.Length)
{
int chunk = Math.Min(textWidth, props.Text.Length - offset);
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(indent);
Console.Write(props.Text[offset..(offset + chunk)]);
@@ -17,7 +17,7 @@ public record TextPanelProps : ConsoleReactiveProps
/// <summary>
/// A component that renders a list of pre-rendered string items vertically.
/// Designed for rendering dynamic items in a non-scroll region that may be
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveProps.Height"/>
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
/// exceeds the number of output lines, leftover lines are erased.
/// </summary>
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
@@ -51,18 +51,18 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
for (int j = 0; j < lineCount; j++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + currentRow));
Console.Write(lines[j]);
currentRow++;
}
}
// If the component height exceeds the output, erase leftover lines
if (props.Height > currentRow)
if (this.Height > currentRow)
{
for (int i = currentRow; i < props.Height; i++)
for (int i = currentRow; i < this.Height; i++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i));
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + i));
}
}
}
@@ -52,7 +52,7 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
}
// Move cursor to the bottom of the scroll area
Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + this.Height - 1, this.X));
// Output only new items since last rendered
for (int i = state.RenderedCount; i < props.Items.Count; i++)
@@ -9,6 +9,9 @@ namespace Harness.ConsoleReactiveComponents;
/// </summary>
public record TopBottomRuleProps : ConsoleReactiveProps
{
/// <summary>Gets the width of the horizontal rules in characters.</summary>
public int Width { get; init; }
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
public ConsoleColor? Color { get; init; }
}
@@ -29,7 +32,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
int childrenHeight = 0;
foreach (var child in props.Children)
{
childrenHeight += child.BaseProps?.Height ?? 0;
childrenHeight += child.Height;
}
// Top rule + children + bottom rule
@@ -48,11 +51,11 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
}
// Top rule
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
Console.Write(rule);
// Render children stacked below the top rule
int currentY = props.Y + 1;
int currentY = this.Y + 1;
if (props.Color.HasValue)
{
@@ -61,9 +64,10 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
foreach (var child in props.Children)
{
child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY };
child.X = this.X;
child.Y = currentY;
child.Render();
currentY += child.BaseProps.Height;
currentY += child.Height;
}
if (props.Color.HasValue)
@@ -72,7 +76,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
}
// Bottom rule
Console.Write(AnsiEscapes.MoveCursor(currentY, props.X));
Console.Write(AnsiEscapes.MoveCursor(currentY, this.X));
Console.Write(rule);
if (props.Color.HasValue)
@@ -3,8 +3,8 @@
namespace Harness.ConsoleReactiveFramework;
/// <summary>
/// Abstract base class for all console UI components. Provides access to layout
/// through <see cref="BaseProps"/> and a <see cref="Render"/> method for drawing to the console.
/// Abstract base class for all console UI components. Provides layout properties
/// (position and size) and a <see cref="Render"/> method for drawing to the console.
/// Derive from <see cref="ConsoleReactiveComponent{TProps, TState}"/> instead of this class directly.
/// </summary>
public abstract class ConsoleReactiveComponent
@@ -13,21 +13,20 @@ public abstract class ConsoleReactiveComponent
{
}
/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
/// knowing the concrete props type.
/// </summary>
public abstract ConsoleReactiveProps? BaseProps { get; set; }
/// <summary>Gets or sets the 1-based column position of the component.</summary>
public int X { get; set; }
/// <summary>Gets or sets the 1-based row position of the component.</summary>
public int Y { get; set; }
/// <summary>Gets or sets the width of the component in columns.</summary>
public int Width { get; set; }
/// <summary>Gets or sets the height of the component in rows.</summary>
public int Height { get; set; }
/// <summary>Renders the component to the console at its current position.</summary>
public abstract void Render();
/// <summary>
/// Invalidates the component's cached render state, causing the next <see cref="Render"/> call
/// to proceed even if props and state have not changed. Use after a screen erase to force repaint.
/// </summary>
public abstract void Invalidate();
}
/// <summary>
@@ -47,13 +46,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// <summary>Gets or sets the component's props (external configuration).</summary>
public TProps? Props { get; set; }
/// <inheritdoc/>
public override ConsoleReactiveProps? BaseProps
{
get => this.Props;
set => this.Props = (TProps?)value;
}
/// <summary>Gets or sets the component's internal state.</summary>
protected TState? State { get; set; }
@@ -81,8 +73,8 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
return;
}
if (EqualityComparer<TProps>.Default.Equals(this.Props, this._lastRenderedProps)
&& EqualityComparer<TState>.Default.Equals(this.State, this._lastRenderedState))
if (ReferenceEquals(this.Props, this._lastRenderedProps)
&& ReferenceEquals(this.State, this._lastRenderedState))
{
return;
}
@@ -94,16 +86,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
}
}
/// <inheritdoc/>
public override void Invalidate()
{
lock (this._renderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
}
}
/// <summary>
/// Called by <see cref="Render"/> to perform the actual rendering. Override this in derived classes.
/// </summary>
@@ -113,23 +95,11 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
}
/// <summary>
/// Base record for component props. Provides layout properties (position and size)
/// and an optional <see cref="Children"/> collection for composing child components.
/// Base record for component props. Provides an optional <see cref="Children"/> collection
/// for composing child components.
/// </summary>
public record ConsoleReactiveProps
{
/// <summary>Gets the 1-based column position of the component.</summary>
public int X { get; init; }
/// <summary>Gets the 1-based row position of the component.</summary>
public int Y { get; init; }
/// <summary>Gets the width of the component in columns.</summary>
public int Width { get; init; }
/// <summary>Gets the height of the component in rows.</summary>
public int Height { get; init; }
/// <summary>Gets the child components to render within this component.</summary>
public IReadOnlyList<ConsoleReactiveComponent> Children { get; init; } = [];
}
@@ -43,7 +43,7 @@ public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps,
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y));
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
bool hasMode = props.Mode is not null;
@@ -35,7 +35,6 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
];
private readonly Timer _timer;
private AgentStatusProps? _previousProps;
/// <summary>
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
@@ -86,12 +85,7 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
if (props != this._previousProps)
{
System.Console.Write(AnsiEscapes.EraseToEndOfLine);
this._previousProps = props;
}
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
if (props.ShowSpinner)
{
@@ -192,11 +192,6 @@ 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)
@@ -371,7 +371,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
listProps = listProps with { Height = bottomChildHeight };
this._listSelection.Height = bottomChildHeight;
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
@@ -398,7 +398,8 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
}
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
@@ -412,7 +413,8 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
@@ -457,16 +459,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._textScrollPanel.Reset();
this._resizedSinceLastRender = false;
// Invalidate all children so they re-render even if props haven't changed
this._rule.Invalidate();
this._textScrollPanel.Invalidate();
this._textPanel.Invalidate();
this._queuedPanel.Invalidate();
this._agentStatus.Invalidate();
this._modeAndHelp.Invalidate();
this._textInput.Invalidate();
this._listSelection.Invalidate();
}
this._scrollRegionBottom = scrollBottom;
@@ -478,35 +470,35 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
: [];
this._textScrollPanel.X = 1;
this._textScrollPanel.Y = 1;
this._textScrollPanel.Width = state.ConsoleWidth;
this._textScrollPanel.Height = scrollBottom;
this._textScrollPanel.Props = new TextScrollPanelProps
{
X = 1,
Y = 1,
Width = state.ConsoleWidth,
Height = scrollBottom,
Items = scrollItems,
};
this._textScrollPanel.Render();
// Render the text panel for the last (dynamic) item just below the scroll region
this._textPanel.X = 1;
this._textPanel.Y = scrollBottom + 1;
this._textPanel.Width = state.ConsoleWidth;
this._textPanel.Height = textPanelHeight;
this._textPanel.Props = new TextPanelProps
{
X = 1,
Y = scrollBottom + 1,
Width = state.ConsoleWidth,
Height = textPanelHeight,
Items = lastItems,
};
this._textPanel.Render();
// Render queued input items between text panel and agent status
int queuedPanelY = scrollBottom + textPanelHeight + 1;
this._queuedPanel.X = 1;
this._queuedPanel.Y = queuedPanelY;
this._queuedPanel.Width = state.ConsoleWidth;
this._queuedPanel.Height = queuedPanelHeight;
this._queuedPanel.Props = new TextPanelProps
{
X = 1,
Y = queuedPanelY,
Width = state.ConsoleWidth,
Height = queuedPanelHeight,
Items = state.QueuedItems,
};
this._queuedPanel.Render();
@@ -515,41 +507,32 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
int agentStatusY = queuedPanelY + queuedPanelHeight;
if (showStatusAndHelp)
{
this._agentStatus.Props = agentStatusProps with
{
X = 1,
Y = agentStatusY,
Width = state.ConsoleWidth,
Height = agentStatusHeight,
};
this._agentStatus.X = 1;
this._agentStatus.Y = agentStatusY;
this._agentStatus.Width = state.ConsoleWidth;
this._agentStatus.Height = agentStatusHeight;
this._agentStatus.Props = agentStatusProps;
this._agentStatus.Render();
}
// Render the bottom rule + child below the agent status
this._rule.Props = ruleProps with
{
X = 1,
Y = agentStatusY + agentStatusHeight,
};
this._rule.X = 1;
this._rule.Y = agentStatusY + agentStatusHeight;
this._rule.Props = ruleProps;
this._rule.Render();
// Render the mode-and-help line below the bottom rule
if (showStatusAndHelp)
{
int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight;
this._modeAndHelp.Props = modeAndHelpProps with
{
X = 1,
Y = modeAndHelpY,
Width = state.ConsoleWidth,
Height = modeAndHelpHeight,
};
int modeAndHelpY = this._rule.Y + ruleHeight;
this._modeAndHelp.X = 1;
this._modeAndHelp.Y = modeAndHelpY;
this._modeAndHelp.Width = state.ConsoleWidth;
this._modeAndHelp.Height = modeAndHelpHeight;
this._modeAndHelp.Props = modeAndHelpProps;
this._modeAndHelp.Render();
}
// Clear the bottom padding line
System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight));
// Position cursor for natural typing appearance
this.PositionCursor(state);
}
@@ -563,7 +546,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
int textWidth = state.ConsoleWidth - promptLength;
int textLength = state.InputText.Length;
int textInputY = (this._rule.Props?.Y ?? 0) + 1;
int textInputY = this._rule.Y + 1;
if (textWidth <= 0 || textLength == 0)
{
@@ -581,7 +564,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
{
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count;
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
@@ -25,8 +24,6 @@ public static class HarnessConsole
{
options ??= new();
System.Console.OutputEncoding = Encoding.UTF8;
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
var observers = options.Observers
?? HarnessConsoleOptions.BuildDefaultObservers();
@@ -36,9 +33,7 @@ public static class HarnessConsole
var modeProvider = agent.GetService<AgentModeProvider>();
var messageInjector = agent.GetService<MessageInjectingChatClient>();
AgentSession session = options.SessionFactory is not null
? await options.SessionFactory(agent)
: await agent.CreateSessionAsync();
AgentSession session = await agent.CreateSessionAsync();
using var component = new HarnessAppComponent(
placeholder: userPrompt,
@@ -68,7 +63,6 @@ public static class HarnessConsole
System.Console.ResetColor();
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
System.Console.WriteLine("Goodbye!");
@@ -45,12 +45,6 @@ public class HarnessConsoleOptions
/// </summary>
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets an optional factory for creating the <see cref="AgentSession"/>.
/// When <see langword="null"/> (the default), <see cref="AIAgent.CreateSessionAsync"/> is used.
/// </summary>
public Func<AIAgent, Task<AgentSession>>? SessionFactory { get; set; }
/// <summary>
/// Creates the default set of observers without planning support.
/// Includes tool call display, tool approval, error display, reasoning display,
@@ -24,17 +24,6 @@ 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>
@@ -1,19 +0,0 @@
<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>
@@ -1,61 +0,0 @@
// 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;
}
}
}
@@ -16,7 +16,6 @@
<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>
@@ -3,18 +3,19 @@
#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 Harness.Shared.Console.OpenAI;
namespace SampleApp;
/// <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>
public sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
{
private const int MaxQueryDisplayLength = 120;
@@ -19,7 +19,6 @@ 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;
@@ -108,7 +107,6 @@ await HarnessConsole.RunAgentAsync(
{
Observers = [
new OpenAIResponsesWebSearchDisplayObserver(),
new OpenAIResponsesErrorObserver(),
.. HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
@@ -16,7 +16,6 @@
<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>
@@ -16,7 +16,6 @@ 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;
@@ -103,7 +102,10 @@ 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,
BackgroundAgents = [webSearchAgent],
AIContextProviders =
[
new BackgroundAgentsProvider([webSearchAgent]),
],
ChatOptions = new ChatOptions
{
Instructions = parentInstructions,
@@ -114,8 +116,4 @@ AIAgent parentAgent =
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
parentAgent,
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):",
options: new HarnessConsoleOptions
{
Observers = [new OpenAIResponsesErrorObserver(), .. HarnessConsoleOptions.BuildDefaultObservers()],
});
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
</ItemGroup>
<ItemGroup>
<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="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -1,122 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a HarnessAgent with ALL features enabled, plus:
// - Hyperlight CodeAct (HyperlightCodeActProvider) for sandboxed Python code execution
// - Skills (AgentSkillsProvider) discovering a local "regex-tester" skill
//
// The agent can plan tasks with todos, manage modes, store memories, read/write files,
// search the web, approve sensitive tools, discover and use skills, and execute arbitrary
// Python code in a Hyperlight sandbox — all pre-configured by the HarnessAgent.
//
// Try asking: "Help me write a regex that matches valid email addresses, then test it."
//
// Special commands:
// /todos — Display the current todo list without invoking the agent.
// /mode — Get or set the current agent mode.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using HyperlightSandbox.Guest.Python;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.CodeExecution";
// Set up OpenTelemetry tracing that writes spans to a text file.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create the HyperlightCodeActProvider with the Python/Wasm backend.
// The guest module path is resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
using var codeAct = new HyperlightCodeActProvider(
HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
var instructions =
"""
## Technical Assistant Instructions
You are a code-powered technical assistant. You can execute Python code in a sandboxed environment
to solve problems precisely rather than guessing. You also have access to skills that provide
structured workflows for specific technical tasks.
### Code Execution
When a problem requires computation, validation, or testing:
- Write Python code and use `execute_code` to run it in the sandbox.
- Always verify results by running the code rather than reasoning about what would happen.
- If code fails, read the error message carefully, fix the issue, and retry.
### Skills
You have access to discoverable skills. When a task matches a skill's description:
- Follow the skill's instructions carefully.
- Use the skill's reference materials for context.
- Combine the skill's workflow with code execution when appropriate.
### Planning and Research
For complex tasks:
- Break the problem into steps using your todo list.
- Research background information using web search when needed.
- Save important findings to file memory for later reference.
### Presenting Results
- Show your work: include the code you ran and its output.
- Explain what each part of your solution does.
- If applicable, save final results to file memory.
""";
// Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct.
// No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess,
// ToolApproval, WebSearch, and AgentSkillsProvider are all active.
AIAgent agent =
new AIProjectClient(
new Uri(endpoint),
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "CodeExecutionAgent",
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
OpenTelemetrySourceName = TracingSourceName,
// Point the file memory at a local folder for persistent memory across sessions.
FileMemoryStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
// Add the HyperlightCodeActProvider so the agent can execute Python code in a sandbox.
AIContextProviders = [codeAct],
ChatOptions = new ChatOptions
{
Instructions = instructions,
MaxOutputTokens = MaxOutputTokens,
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
});
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
userPrompt: "Ask me a technical question, or try: \"Help me write a regex that matches valid email addresses.\"",
new HarnessConsoleOptions
{
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens),
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
@@ -1,51 +0,0 @@
# Harness Step 04 — Code Execution (Hyperlight + Skills)
This sample demonstrates a HarnessAgent with **all features enabled**, plus:
- **Hyperlight CodeAct** — sandboxed Python code execution via `execute_code` (requires KVM)
- **Skills** — file-based skill discovery (a `regex-tester` skill is included)
The agent can plan tasks, manage modes, store memories, read/write files, search the web, approve sensitive operations, discover and use skills, and execute arbitrary Python code — all pre-configured by the HarnessAgent.
## Prerequisites
- .NET 10 SDK
- An Azure AI Foundry project endpoint
- KVM-capable host (the Hyperlight sandbox runs code in micro-VMs)
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AZURE_AI_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name (default: `gpt-5.4`) |
## Running
```bash
dotnet run
```
## What to Try
- **Regex testing**: "Help me write a regex that matches valid email addresses, then test it against some examples."
- **Code execution**: "Calculate the first 20 prime numbers using the Sieve of Eratosthenes."
- **Skill + code combo**: "I need a regex for ISO 8601 dates — test it thoroughly with edge cases."
## Included Skill
The `skills/regex-tester/` skill instructs the agent to validate regex patterns by executing Python test code in the Hyperlight sandbox. It includes a regex cheatsheet as reference material.
## Features Enabled
| Feature | Description |
|---------|-------------|
| TodoProvider | Task planning and tracking (`/todos` command) |
| AgentModeProvider | Mode switching (`/mode` command) |
| FileMemoryProvider | Persistent memory stored as files |
| FileAccessProvider | Read/write files in a working directory |
| ToolApproval | Don't-ask-again approval for sensitive tools |
| WebSearch | Built-in hosted web search |
| AgentSkillsProvider | Discovers and uses skills from the `skills/` folder |
| HyperlightCodeActProvider | Sandboxed Python execution via `execute_code` |
| OpenTelemetry | Trace logging to a text file |
@@ -1,36 +0,0 @@
---
name: regex-tester
description: Validate, test, and debug regular expressions by executing them against sample inputs. Use when asked to build, verify, or explain a regex pattern.
---
## Usage
When the user asks you to create, validate, or debug a regular expression:
1. **Understand the requirement** — clarify what the pattern should match and what it should reject.
2. **Consult the cheatsheet** — review `references/regex-cheatsheet.md` for syntax reminders if needed.
3. **Write and execute test code** — use the `execute_code` tool to run Python code that:
- Compiles the regex with `re.compile()`
- Tests it against a set of positive examples (should match) and negative examples (should not match)
- Extracts and displays any capturing groups
- Reports pass/fail for each test case
4. **Iterate** — if any test fails, refine the pattern and re-run until all cases pass.
5. **Present the result** — give the user the final pattern, explain what each part does, and show the test results.
## Example Test Script
```python
import re
pattern = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
positives = ["user@example.com", "first.last+tag@sub.domain.org"]
negatives = ["@missing.com", "no-at-sign", "spaces in@address.com"]
for s in positives:
assert pattern.match(s), f"FAIL: expected match for '{s}'"
for s in negatives:
assert not pattern.match(s), f"FAIL: expected no match for '{s}'"
print("All tests passed!")
```
@@ -1,97 +0,0 @@
# Regex Quick Reference (Python `re` module)
## Character Classes
| Pattern | Matches |
|---------|---------|
| `.` | Any character except newline |
| `\d` | Digit `[0-9]` |
| `\D` | Non-digit |
| `\w` | Word character `[a-zA-Z0-9_]` |
| `\W` | Non-word character |
| `\s` | Whitespace `[ \t\n\r\f\v]` |
| `\S` | Non-whitespace |
| `[abc]` | Any of a, b, or c |
| `[^abc]`| Any character except a, b, c |
| `[a-z]` | Range: a through z |
## Quantifiers
| Pattern | Meaning |
|---------|---------|
| `*` | 0 or more (greedy) |
| `+` | 1 or more (greedy) |
| `?` | 0 or 1 (greedy) |
| `{n}` | Exactly n |
| `{n,}` | n or more |
| `{n,m}` | Between n and m |
| `*?`, `+?`, `??` | Non-greedy versions |
## Anchors
| Pattern | Meaning |
|---------|---------|
| `^` | Start of string (or line with `re.MULTILINE`) |
| `$` | End of string (or line with `re.MULTILINE`) |
| `\b` | Word boundary |
| `\B` | Non-word boundary |
## Groups and Backreferences
| Pattern | Meaning |
|---------|---------|
| `(...)` | Capturing group |
| `(?:...)`| Non-capturing group |
| `(?P<name>...)` | Named group |
| `\1` | Backreference to group 1 |
| `(?=...)` | Positive lookahead |
| `(?!...)` | Negative lookahead |
| `(?<=...)` | Positive lookbehind |
| `(?<!...)` | Negative lookbehind |
## Flags
| Flag | Effect |
|------|--------|
| `re.IGNORECASE` / `re.I` | Case-insensitive matching |
| `re.MULTILINE` / `re.M` | `^`/`$` match line boundaries |
| `re.DOTALL` / `re.S` | `.` matches newline |
| `re.VERBOSE` / `re.X` | Allow comments and whitespace |
## Common Patterns
| Use Case | Pattern |
|----------|---------|
| Email (simple) | `^[\w.+-]+@[\w-]+\.[\w.-]+$` |
| IPv4 address | `^\d{1,3}(\.\d{1,3}){3}$` |
| ISO date | `^\d{4}-\d{2}-\d{2}$` |
| URL (http/https) | `^https?://[^\s/$.?#].[^\s]*$` |
| Phone (US) | `^(\+1)?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$` |
## Python API
```python
import re
# Test if a string matches
re.match(r'pattern', "string") # match at start
re.search(r'pattern', "string") # match anywhere
re.fullmatch(r'pattern', "string") # match entire string
# Find all matches
re.findall(r'\d+', "abc 123 def 456") # ['123', '456']
# Named groups
m = re.match(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})', "2025-01-15")
m.group('year') # '2025'
# Replace
re.sub(r'\d+', 'X', "abc 123 def") # 'abc X def'
# Split
re.split(r',+', "a,b,,c") # ['a', 'b', 'c']
# Compile for reuse
pattern = re.compile(r'^\d{4}-\d{2}-\d{2}$')
pattern.match("2025-01-15") # Match object
```
@@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;MEAI001;MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,145 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates the Microsoft Agent Framework's MCP long-running task support.
//
// A small MCP server (hosted in this same executable when launched with "--server") exposes
// a single task-supporting tool "AnalyzeDataset" that simulates ~15 seconds of work. The
// client (default mode) connects to it over stdio via Microsoft.Agents.AI.Mcp's
// McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync, hands the wrapped tools to a
// ChatClientAgent, and exercises both invocation styles:
// * RunAsync — blocks until the agent's final response is ready.
// * RunStreamingAsync — yields response updates as the model produces them; the model
// still waits for the tool's terminal result before it can begin
// producing the final answer, so the perceived "pause" reflects
// tool execution time, not stream-channel latency.
//
// In both cases the wrapper transparently:
// 1. Calls tools/call with task augmentation (CallToolAsTaskAsync)
// 2. Polls tasks/get until terminal (PollTaskUntilCompleteAsync)
// 3. Fetches tasks/result and returns the final result to the function-calling loop
//
// No application-level loop or continuation tokens are required in either mode.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Mcp;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using OpenAI.Chat;
if (args.Length > 0 && args[0] == "--server")
{
await RunMcpServerAsync();
return;
}
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// Launch this same assembly as a stdio MCP server in a child process.
var thisAssemblyPath = typeof(Program).Assembly.Location;
await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new()
{
Name = "DatasetAnalyzer",
Command = "dotnet",
Arguments = [thisAssemblyPath, "--server"],
}));
// Wrap each MCP tool with task-aware behavior. The wrapper inspects the server's
// execution.taskSupport on each tool and, when it is Required, drives the task lifecycle
// transparently within the agent's tool loop. Tools that don't require task semantics are
// returned as-is and invoked inline.
var taskOptions = new McpTaskOptions
{
DefaultTimeToLive = TimeSpan.FromMinutes(5),
};
var mcpTools = await mcpClient.ListAgentToolsWithTaskSupportAsync(taskOptions);
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
instructions: "You answer data-analysis questions by invoking the available tools. Always invoke a tool when one matches the request.",
tools: [.. mcpTools.Cast<AITool>()]);
const string Prompt = "Analyze the dataset named 'sales-2025-q1' and summarize the findings.";
Console.WriteLine("=== Transparent long-running MCP task (RunAsync) ===");
Console.WriteLine("Asking the agent to analyze a dataset; the tool takes ~15s to complete.");
Console.WriteLine("RunAsync blocks while the wrapper polls the task to completion.");
Console.WriteLine();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await agent.RunAsync(Prompt);
stopwatch.Stop();
Console.WriteLine($"Agent response (after {stopwatch.Elapsed.TotalSeconds:F1}s):");
Console.WriteLine(response.Text);
Console.WriteLine();
Console.WriteLine("=== Transparent long-running MCP task (RunStreamingAsync) ===");
Console.WriteLine("Same request via the streaming API. Updates only begin to arrive after the");
Console.WriteLine("tool's task reaches the Completed state, since the model needs the tool result");
Console.WriteLine("before it can produce its final answer.");
Console.WriteLine();
stopwatch.Restart();
await foreach (var update in agent.RunStreamingAsync(Prompt))
{
Console.Write(update.Text);
}
stopwatch.Stop();
Console.WriteLine();
Console.WriteLine($"(Streaming completed after {stopwatch.Elapsed.TotalSeconds:F1}s.)");
// --- Server mode (launched as a child process via --server) ---------------------------------
static async Task RunMcpServerAsync()
{
var builder = Host.CreateApplicationBuilder();
// Critical for stdio transport: any provider that writes to stdout will corrupt the
// JSON-RPC channel. Clear all providers; the MCP SDK routes its own diagnostics
// appropriately.
builder.Logging.ClearProviders();
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMcpServer(o =>
{
o.TaskStore = new InMemoryMcpTaskStore();
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" };
})
.WithStdioServerTransport()
.WithTools<DatasetAnalysisTools>();
await builder.Build().RunAsync();
}
#pragma warning disable CA1812 // Discovered by MCP SDK via [McpServerToolType] attribute
[McpServerToolType]
internal sealed class DatasetAnalysisTools
#pragma warning restore CA1812
{
[McpServerTool(Name = "AnalyzeDataset", TaskSupport = ToolTaskSupport.Required)]
[Description("Analyze a tabular dataset and return summary statistics. This tool simulates a long-running analytic job (~15 seconds).")]
public static async Task<string> AnalyzeDatasetAsync(
[Description("The dataset identifier, e.g. 'sales-2025-q1'.")] string datasetName,
CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
return $"Findings for '{datasetName}': 12,403 rows; avg revenue $48,712; 3 anomalies detected in week 7; outliers concentrated in EMEA region.";
}
}
@@ -1,60 +0,0 @@
# Agent with MCP long-running task (transparent polling)
This sample demonstrates Microsoft Agent Framework's MCP long-running task support: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
## What this sample shows
- Using `McpClient.ListAgentToolsWithTaskSupportAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
- Configuring `McpTaskOptions.DefaultTimeToLive` to bound the server-side task.
- Hosting a small MCP server (in this same executable, launched with `--server`) that advertises `execution.taskSupport=required` on a tool that sleeps for ~15 seconds.
- No application-level polling, continuation tokens, or `AllowBackgroundResponses` flag are required.
The decorator drives the lifecycle internally:
1. `tools/call` augmented with task metadata (`CallToolAsTaskAsync`)
2. `tasks/get` polled until terminal (`PollTaskUntilCompleteAsync`)
3. `tasks/result` retrieved (`GetTaskResultAsync`) and returned to the function-calling loop
The sample exercises both invocation styles against the same wrapper:
- `agent.RunAsync(...)` blocks until the tool completes (~15 seconds in this sample) and returns the final response.
- `agent.RunStreamingAsync(...)` returns immediately and yields `AgentResponseUpdate` chunks as the model emits them; in this scenario the model only begins streaming its answer once the wrapped tool's task reaches the `Completed` state, so the perceived "pause" before tokens arrive reflects tool execution time, not stream-channel latency.
# Prerequisites
- .NET 10 SDK or later
- Azure OpenAI service endpoint and a chat-completions deployment
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # optional; defaults to gpt-5.4-mini
```
# Running
```powershell
cd Agent_MCP_LongRunningTask_Client
dotnet run
```
You should see output similar to:
```
=== Transparent long-running MCP task (RunAsync) ===
Asking the agent to analyze a dataset; the tool takes ~15s to complete.
RunAsync blocks while the wrapper polls the task to completion.
Agent response (after 15.4s):
The 'sales-2025-q1' dataset contains 12,403 rows ...
=== Transparent long-running MCP task (RunStreamingAsync) ===
Same request via the streaming API. Updates only begin to arrive after the
tool's task reaches the Completed state, since the model needs the tool result
before it can produce its final answer.
The 'sales-2025-q1' dataset contains 12,403 rows ...
(Streaming completed after 15.7s.)
```
@@ -22,7 +22,6 @@ Before you begin, ensure you have the following prerequisites:
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
## Running the samples from the console
@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -1,193 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample ports the Python Magentic orchestration sample to .NET.
// A Magentic workflow coordinates a researcher and a coder, streams orchestration
// events as the plan evolves, and prints the final conversation transcript.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;
namespace WorkflowMagenticOrchestrationSample;
/// <summary>
/// Demonstrates Magentic orchestration with a researcher, a coder, and an LLM manager.
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - An Azure AI Foundry project endpoint and model deployment must be configured.
/// - Run <c>az login</c> before executing the sample.
/// </remarks>
public static class Program
{
private const string TaskPrompt =
"I am preparing a report on the energy efficiency of different machine learning model architectures. " +
"Compare the estimated training and inference energy consumption of ResNet-50, BERT-base, and GPT-2 " +
"on standard datasets (e.g., ImageNet for ResNet, GLUE for BERT, WebText for GPT-2). " +
"Then, estimate the CO2 emissions associated with each, assuming training on an Azure Standard_NC6s_v3 " +
"VM for 24 hours. Provide tables for clarity, and recommend the most energy-efficient model " +
"per task type (image classification, text classification, and text generation).";
private static async Task Main()
{
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
AIAgent researcherAgent = projectClient.AsAIAgent(
deploymentName,
name: "ResearcherAgent",
description: "Specialist in research and information gathering.",
instructions: "You are a researcher. Find relevant information without doing additional computation or quantitative analysis.");
AIAgent coderAgent = projectClient.AsAIAgent(
deploymentName,
name: "CoderAgent",
description: "A helpful assistant that writes and executes code to analyze data.",
instructions: "You solve quantitative questions by writing and running code. Show the analysis and the computation process clearly.",
tools: [new HostedCodeInterpreterTool()]);
AIAgent managerAgent = projectClient.AsAIAgent(
deploymentName,
name: "MagenticManager",
description: "Orchestrator that coordinates the research and coding workflow.",
instructions: "You coordinate the team to complete complex tasks efficiently.");
Workflow workflow = new MagenticWorkflowBuilder(managerAgent)
.AddParticipants([researcherAgent, coderAgent])
.WithName("Magentic Orchestration Workflow")
.WithDescription("Coordinates a researcher and coder to solve a complex analytical task.")
.RequirePlanSignoff(false)
.WithMaxRounds(10)
.WithMaxStalls(3)
.WithMaxResets(2)
.Build();
Console.WriteLine("Building Magentic workflow...");
Console.WriteLine();
Console.WriteLine($"Task: {TaskPrompt}");
Console.WriteLine();
Console.WriteLine("Starting workflow execution...");
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(
workflow,
new List<ChatMessage> { new(ChatRole.User, TaskPrompt) });
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
string? lastResponseId = null;
WorkflowOutputEvent? finalOutput = null;
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
{
switch (workflowEvent)
{
case AgentResponseUpdateEvent updateEvent:
WriteStreamingUpdate(updateEvent, ref lastResponseId);
break;
case MagenticPlanCreatedEvent planCreated:
WriteMagenticMessage("Initial Plan", planCreated.FullTaskLedger.Text);
PauseIfInteractive();
break;
case MagenticReplannedEvent replanned:
WriteMagenticMessage("Replanned", replanned.FullTaskLedger.Text);
PauseIfInteractive();
break;
case MagenticProgressLedgerUpdatedEvent progressUpdated:
WriteMagenticMessage("Progress Ledger", FormatProgressLedger(progressUpdated.ProgressLedger));
PauseIfInteractive();
break;
case WorkflowOutputEvent outputEvent when outputEvent.Is<List<ChatMessage>>():
finalOutput = outputEvent;
break;
case WorkflowErrorEvent workflowError:
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine(workflowError.Exception?.ToString() ?? "Unknown workflow error occurred.");
Console.ResetColor();
break;
case ExecutorFailedEvent executorFailed:
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Executor '{executorFailed.ExecutorId}' failed with {(executorFailed.Data is null ? "unknown error" : $"exception {executorFailed.Data}")}.");
Console.ResetColor();
break;
}
}
if (finalOutput?.As<List<ChatMessage>>() is { } transcript)
{
Console.WriteLine();
Console.WriteLine(new string('=', 80));
Console.WriteLine();
Console.WriteLine("Final Conversation Transcript:");
Console.WriteLine();
foreach (ChatMessage message in transcript)
{
Console.WriteLine($"{message.AuthorName ?? message.Role.ToString()}: {message.Text}");
Console.WriteLine();
}
}
}
private static void WriteStreamingUpdate(AgentResponseUpdateEvent updateEvent, ref string? lastResponseId)
{
string responseId = updateEvent.Update.ResponseId ?? updateEvent.Update.MessageId ?? updateEvent.ExecutorId;
if (!string.Equals(responseId, lastResponseId, StringComparison.Ordinal))
{
if (lastResponseId is not null)
{
Console.WriteLine();
Console.WriteLine();
}
Console.Write($"- {updateEvent.ExecutorId}: ");
lastResponseId = responseId;
}
if (!string.IsNullOrEmpty(updateEvent.Update.Text))
{
Console.Write(updateEvent.Update.Text);
}
}
private static void WriteMagenticMessage(string title, string? content)
{
Console.WriteLine();
Console.WriteLine($"[Magentic {title}]");
Console.WriteLine(content);
}
private static string FormatProgressLedger(MagenticProgressLedger ledger) =>
string.Join(Environment.NewLine,
$"Request satisfied: {ledger.IsRequestSatisfied}",
$"In loop: {ledger.IsInLoop}",
$"Making progress: {ledger.IsProgressBeingMade}",
$"Next speaker: {ledger.NextSpeaker}",
$"Instruction: {ledger.InstructionOrQuestion}");
private static void PauseIfInteractive()
{
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{
return;
}
Console.Write("Press Enter to continue...");
Console.ReadLine();
Console.WriteLine();
}
}
@@ -1,40 +0,0 @@
# Magentic Orchestration Sample
This sample showcases the Magentic Orchestration Pattern in .NET, setting up a team with three roles:
- **ResearcherAgent** gathers factual background information.
- **CoderAgent** uses `HostedCodeInterpreterTool` for quantitative analysis.
- **MagenticManager** plans the work, tracks progress, and decides who should act next.
## What This Sample Demonstrates
- Building a Magentic workflow with `MagenticWorkflowBuilder`
- Combining standard responses-based agents with a code interpreter-enabled participant
- Streaming orchestration events such as the initial plan, replans, and progress-ledger updates
- Printing the final multi-agent conversation transcript
## Prerequisites
- `AZURE_AI_PROJECT_ENDPOINT` set to your Azure AI Foundry project endpoint
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` set to your model deployment name (defaults to `gpt-5.4-mini`)
- `az login` completed before running the sample
## Running the Sample
```bash
dotnet run
```
## Expected Output
The sample prints:
1. The original task prompt
2. Streamed updates from the participating agents
3. Magentic plan and progress-ledger events as the workflow coordinates the team
4. The final conversation transcript returned by the workflow
## Related Samples
- [Handoff Orchestration](../Handoff) - another multi-agent orchestration pattern in .NET workflows
- [Python Magentic workflow sample](../../../../../python/samples/03-workflows/orchestrations/magentic.py) - the source scenario that this sample ports
-1
View File
@@ -62,4 +62,3 @@ Once completed, please proceed to the other samples listed below.
| Sample | Concepts |
|--------|----------|
| [Handoff Orchestration](./Orchestration/Handoff) | Introduces the Handoff Orchestration pattern |
| [Magentic Orchestration](./Orchestration/Magentic) | Coordinates multiple agents with a Magentic manager, streamed plan events, and a final transcript |
+10 -29
View File
@@ -28,7 +28,9 @@ public sealed class A2AAgent : AIAgent
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
private readonly IA2AClient _a2aClient;
private readonly A2AAgentOptions _agentOptions;
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
private readonly ILogger _logger;
/// <summary>
@@ -36,37 +38,17 @@ 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 name of the agent.</param>
/// <param name="name">The 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._agentOptions = options.Clone();
this._id = id;
this._name = name;
this._description = description;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
}
@@ -234,13 +216,13 @@ public sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
protected override string? IdCore => this._agentOptions.Id;
protected override string? IdCore => this._id;
/// <inheritdoc/>
public override string? Name => this._agentOptions.Name;
public override string? Name => this._name;
/// <inheritdoc/>
public override string? Description => this._agentOptions.Description;
public override string? Description => this._description;
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
@@ -474,7 +456,6 @@ public sealed class A2AAgent : AIAgent
ResponseId = statusUpdateEvent.TaskId,
RawRepresentation = statusUpdateEvent,
Role = ChatRole.Assistant,
MessageId = statusUpdateEvent.Status.Message?.MessageId,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
@@ -1,40 +0,0 @@
// 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,9 +2,7 @@
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -38,39 +36,4 @@ 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,7 +6,6 @@ using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -49,39 +48,4 @@ 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,7 +3,6 @@
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -32,32 +31,10 @@ 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 name of the agent.</param>
/// <param name="name">The 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,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Reflection;
@@ -10,11 +9,8 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// 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).
/// 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.
/// </summary>
/// <remarks>
/// <para>
@@ -23,12 +19,6 @@ 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.
@@ -40,12 +30,6 @@ 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);
@@ -62,52 +46,13 @@ 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
@@ -1,88 +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.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;
}
}
@@ -0,0 +1,165 @@
// 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;
}
}
@@ -23,7 +23,7 @@ namespace Azure.AI.Projects;
/// Provides extension methods for <see cref="AIProjectClient"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static partial class AIProjectClientExtensions
public static partial class AzureAIProjectChatClientExtensions
{
/// <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 AIProjectClientExtensions
clientFactory,
services);
return new FoundryAgent(innerAgent);
return new FoundryAgent(aiProjectClient, innerAgent);
}
/// <summary>
@@ -132,7 +132,7 @@ public static partial class AIProjectClientExtensions
!allowDeclarativeMode,
services);
return new FoundryAgent(innerAgent);
return new FoundryAgent(aiProjectClient, innerAgent);
}
/// <summary>
@@ -165,7 +165,7 @@ public static partial class AIProjectClientExtensions
!allowDeclarativeMode,
services);
return new FoundryAgent(innerAgent);
return new FoundryAgent(aiProjectClient, innerAgent);
}
/// <summary>
@@ -246,7 +246,7 @@ public static partial class AIProjectClientExtensions
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
if (clientFactory is not null)
{
@@ -268,7 +268,10 @@ public static partial class AIProjectClientExtensions
Throw.IfNull(agentOptions.ChatOptions);
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
IChatClient chatClient = aiProjectClient
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(agentOptions.ChatOptions.ModelId);
if (clientFactory is not null)
{
@@ -295,7 +298,7 @@ public static partial class AIProjectClientExtensions
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
if (clientFactory is not null)
{
@@ -313,7 +316,7 @@ public static partial class AIProjectClientExtensions
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
if (clientFactory is not null)
{
@@ -0,0 +1,35 @@
// 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
@@ -1,42 +0,0 @@
// 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,6 +39,11 @@ 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>
@@ -68,8 +73,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
: base(CreateInnerAgent(
CreateProjectClient(projectEndpoint, credential, clientOptions),
model, instructions, name, description, tools, clientFactory, loggerFactory, services,
out _))
out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
}
/// <summary>
@@ -81,11 +87,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// </param>
/// <param name="credential">The authentication credential.</param>
/// <param name="clientOptions">
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. 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>
@@ -109,37 +113,43 @@ 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))
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
}
/// <summary>
/// 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.
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
/// </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://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/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(CreateInnerAgentFromAgentEndpointReusingProjectClient(aiProjectClient, agentEndpoint, tools, clientFactory, services))
: base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
}
/// <summary>
/// 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&lt;AIProjectClient&gt;()</c> surfaces
/// the project client to downstream callers, so the agent does not also need a private
/// <see cref="AIProjectClient"/> reference here.
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
/// </summary>
internal FoundryAgent(ChatClientAgent innerAgent)
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
}
#region Convenience methods
@@ -172,13 +182,7 @@ 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)
{
// 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 conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
@@ -192,6 +196,17 @@ 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(
@@ -236,7 +251,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
Throw.IfNull(agentOptions.ChatOptions);
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
if (clientFactory is not null)
{
@@ -273,10 +288,16 @@ public sealed class FoundryAgent : DelegatingAIAgent
}
/// <summary>
/// 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"/>.
/// 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.
/// </summary>
private static AIAgent CreateInnerAgentFromAgentEndpoint(
Uri agentEndpoint,
@@ -284,37 +305,29 @@ public sealed class FoundryAgent : DelegatingAIAgent
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
IServiceProvider? services,
out AIProjectClient outClient)
{
Throw.IfNull(agentEndpoint);
Throw.IfNull(credential);
IChatClient chatClient = new FoundryChatClient(agentEndpoint, credential, clientOptions);
var agentName = ((FoundryChatClient)chatClient).AgentName!;
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
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));
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, 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.
/// 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 CreateInnerAgentFromAgentEndpointReusingProjectClient(
private static AIAgent BuildAgentEndpointInnerAgent(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
@@ -322,9 +335,14 @@ public sealed class FoundryAgent : DelegatingAIAgent
Throw.IfNull(aiProjectClient);
Throw.IfNull(agentEndpoint);
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentEndpoint, clientOptions: null);
var agentName = ((FoundryChatClient)chatClient).AgentName!;
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);
@@ -340,13 +358,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
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://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>
@@ -358,12 +369,90 @@ 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);
return new AIProjectClient(endpoint, credential, clientOptions ?? new AIProjectClientOptions());
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;
}
#endregion
@@ -1,116 +0,0 @@
// 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&lt;FoundryChatClient&gt;().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.");
}
}
@@ -1,703 +0,0 @@
// 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, <c>x-ms-served-model</c>
/// response-header capture, 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);
TryRegisterServedModelPolicy(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);
TryRegisterServedModelPolicy(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://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/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);
TryRegisterServedModelPolicy(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;
var box = new StrongBox<string?>(null);
var previous = ServedModelScope.Current;
ServedModelScope.Current = box;
try
{
var response = await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
if (box.Value is { } servedModel)
{
response.ModelId = servedModel;
}
return response;
}
finally
{
ServedModelScope.Current = previous;
}
}
/// <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;
var box = new StrongBox<string?>(null);
var previous = ServedModelScope.Current;
ServedModelScope.Current = box;
try
{
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
{
if (box.Value is { } servedModel)
{
chunk.ModelId = servedModel;
}
yield return chunk;
}
}
finally
{
ServedModelScope.Current = previous;
}
}
#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://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/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>
/// Best-effort registration of <see cref="ServedModelPolicy"/> via the MEAI
/// <see cref="OpenAIRequestPolicies"/> hook. The policy captures the
/// <c>x-ms-served-model</c> response header from Azure OpenAI and writes it into
/// <see cref="ServedModelScope"/> so the <see cref="GetResponseAsync"/> and
/// <see cref="GetStreamingResponseAsync"/> overrides can overwrite
/// <see cref="ChatResponse.ModelId"/> with the actual model snapshot.
/// </summary>
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
{
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ServedModelPolicy.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; }
}
}
@@ -1,150 +0,0 @@
// 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.");
}
}
@@ -0,0 +1,58 @@
// 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;
}
}
}
@@ -1,67 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Pipeline policy that captures the <c>x-ms-served-model</c> response header from Azure OpenAI
/// and stores it in <see cref="ServedModelScope"/> for consumption by <see cref="FoundryChatClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// Azure OpenAI Responses API returns the deployment alias in <c>response.model</c> but the actual
/// model snapshot (e.g. <c>gpt-5-nano-2025-08-07</c>) in the <c>x-ms-served-model</c> response header.
/// This policy extracts the header after the HTTP roundtrip so the <see cref="FoundryChatClient"/>
/// can overwrite <c>ChatResponse.ModelId</c> with the true model name.
/// </para>
/// <para>
/// Registered once per <c>OpenAIRequestPolicies</c> instance via the MEAI 10.5.1 extension hook.
/// When the header is absent (non-Azure endpoints), the scope is not set and the
/// <see cref="FoundryChatClient"/> preserves the original model name.
/// </para>
/// </remarks>
internal sealed class ServedModelPolicy : PipelinePolicy
{
/// <summary>The Azure OpenAI response header that carries the actual served model name.</summary>
internal const string ServedModelHeader = "x-ms-served-model";
public static ServedModelPolicy Instance { get; } = new ServedModelPolicy();
private ServedModelPolicy()
{
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ProcessNext(message, pipeline, currentIndex);
CaptureServedModel(message);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
CaptureServedModel(message);
}
private static void CaptureServedModel(PipelineMessage message)
{
if (message.Response is null)
{
return;
}
if (message.Response.Headers.TryGetValue(ServedModelHeader, out string? servedModel)
&& !string.IsNullOrWhiteSpace(servedModel))
{
// Write into the box (reference-type mutation) so the value is visible to the
// FoundryChatClient that pushed the box before calling the inner client.
if (ServedModelScope.Current is { } box)
{
box.Value = servedModel.Trim();
}
}
}
}
@@ -1,35 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Threading;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// AsyncLocal carrier that bridges the <c>x-ms-served-model</c> response header value from the
/// <see cref="ServedModelPolicy"/> running inside the SCM transport pipeline up to the
/// <see cref="FoundryChatClient"/> decorator.
/// </summary>
/// <remarks>
/// <para>
/// Because <see cref="AsyncLocal{T}"/> mutations inside a child <c>async</c> method do not propagate
/// back to the caller (copy-on-write semantics), this scope uses <see cref="StrongBox{T}"/> as an
/// indirection layer. The <see cref="FoundryChatClient"/> pushes a fresh box onto the scope
/// before calling the inner client; the <see cref="ServedModelPolicy"/> writes into the box's
/// <see cref="StrongBox{T}.Value"/> (a reference-type mutation visible to anyone holding the same box).
/// After the inner call returns, the client reads the box's value.
/// </para>
/// </remarks>
internal static class ServedModelScope
{
private static readonly AsyncLocal<StrongBox<string?>?> s_current = new();
/// <summary>
/// Gets or sets the per-async-flow served model box.
/// </summary>
public static StrongBox<string?>? Current
{
get => s_current.Value;
set => s_current.Value = value;
}
}
@@ -4,11 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.Agents.AI.Compaction;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -204,14 +200,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
result.Tools.Add(new HostedWebSearchTool());
}
#if NET
if (options?.ShellExecutor is ShellExecutor shellExecutor)
{
result.Tools ??= [];
result.Tools.Add(shellExecutor.AsAIFunction());
}
#endif
return result;
}
@@ -261,22 +249,6 @@ 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 NET
if (options?.ShellExecutor is ShellExecutor shellExecutor)
{
providers.Add(new ShellEnvironmentProvider(shellExecutor, options.ShellEnvironmentProviderOptions));
}
#endif
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
{
providers.AddRange(userProviders);
@@ -2,9 +2,6 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -221,49 +218,4 @@ 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; }
#if NET
/// <summary>
/// Gets or sets the shell executor used to enable shell tool and environment probing via <see cref="ShellEnvironmentProvider"/>.
/// </summary>
/// <remarks>
/// When non-null, a <see cref="ShellEnvironmentProvider"/> is automatically included in the agent's context
/// providers (injecting OS/shell/CWD information into the system prompt), and the executor's
/// <see cref="ShellExecutor.AsAIFunction"/> is registered as a callable tool.
/// When <see langword="null"/> (the default), no shell features are enabled.
/// </remarks>
public ShellExecutor? ShellExecutor { get; set; }
/// <summary>
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
/// </summary>
/// <remarks>
/// Use this to customize which tools are probed, the probe timeout, shell family override,
/// or the instructions formatter.
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </remarks>
public ShellEnvironmentProviderOptions? ShellEnvironmentProviderOptions { get; set; }
#endif
}
@@ -15,10 +15,6 @@
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<ProjectReference Include="..\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Harness</Title>
@@ -1,61 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// Extension methods on <see cref="McpClient"/> that expose MCP server tools to a Microsoft
/// Agent Framework agent with optional long-running task (SEP-2663) handling.
/// </summary>
public static class McpClientTaskExtensions
{
/// <summary>
/// Lists tools advertised by the connected MCP server and returns each as an
/// <see cref="AIFunction"/>. Tools that declare <see cref="ToolTaskSupport.Required"/>
/// are wrapped with task-aware behavior so an agent can transparently drive long-running
/// invocations. All other tools — including those that declare
/// <see cref="ToolTaskSupport.Optional"/> — are returned as-is, preserving inline
/// (synchronous) invocation semantics by default.
/// </summary>
/// <param name="client">The connected MCP client.</param>
/// <param name="options">
/// Options that control the task lifecycle for task-capable tools.
/// When <see langword="null"/>, defaults described on <see cref="McpTaskOptions"/> apply.
/// </param>
/// <param name="cancellationToken">Token used to cancel listing the server's tools.</param>
/// <returns>The tools, ready to pass to <c>AsAIAgent(tools: …)</c>.</returns>
public static async Task<IReadOnlyList<AIFunction>> ListAgentToolsWithTaskSupportAsync(
this McpClient client,
McpTaskOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(client);
McpTaskOptions effectiveOptions = options ?? new McpTaskOptions();
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
AIFunction[] result = new AIFunction[tools.Count];
for (int i = 0; i < tools.Count; i++)
{
ToolTaskSupport? taskSupport = tools[i].ProtocolTool.Execution?.TaskSupport;
if (taskSupport is ToolTaskSupport.Required)
{
result[i] = new TaskAwareMcpClientAIFunction(client, tools[i], effectiveOptions);
}
else
{
result[i] = tools[i];
}
}
return result;
}
}
@@ -1,39 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// Configures how an MCP client wrapper drives the
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP tasks</see>
/// lifecycle when an underlying server tool returns a <c>CreateTaskResult</c>.
/// </summary>
/// <remarks>
/// <para>
/// All members of this type are subject to change. The MCP task surface is experimental
/// and tracks the in-flight specification.
/// </para>
/// </remarks>
public sealed class McpTaskOptions
{
/// <summary>
/// Gets or sets the time-to-live the wrapper attaches to a newly created server-side task.
/// </summary>
/// <remarks>
/// When <see langword="null"/> the wrapper omits the <c>ttl</c> hint and lets the server
/// pick its own value. The server's chosen TTL is always authoritative.
/// </remarks>
public TimeSpan? DefaultTimeToLive { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the wrapper should send
/// <c>tasks/cancel</c> when the local <see cref="System.Threading.CancellationToken"/>
/// fires during a tool invocation.
/// </summary>
/// <remarks>
/// Defaults to <see langword="true"/>: a local cancellation means "the caller is giving up
/// on this tool invocation" and the server-side task has no further consumer.
/// </remarks>
public bool CancelRemoteTaskOnLocalCancellation { get; set; } = true;
}
@@ -1,37 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Mcp</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001;MCPEXP001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<PropertyGroup>
<Title>Microsoft Agent Framework MCP</Title>
<Description>Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including long-running task (SEP-2663) integration for MCP clients.</Description>
</PropertyGroup>
<!-- Disable package validation baseline until the first release -->
<PropertyGroup>
<PackageValidationBaselineVersion />
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Mcp.UnitTests" />
</ItemGroup>
</Project>
@@ -1,147 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// An <see cref="AIFunction"/> wrapper around an <see cref="McpClientTool"/> that drives the
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP long-running task</see>
/// lifecycle (SEP-2663) on behalf of the agent's tool loop.
/// </summary>
/// <remarks>
/// <para>
/// The wrapper invokes the tool with task augmentation via
/// <see cref="McpClient.CallToolAsTaskAsync"/>, polls to completion via
/// <see cref="McpClient.PollTaskUntilCompleteAsync"/>, and fetches the result via
/// <see cref="McpClient.GetTaskResultAsync"/>. The result is returned to the caller as a
/// <see cref="JsonElement"/> containing the serialized <see cref="CallToolResult"/> — the
/// same wire shape produced by <see cref="McpClientTool"/>.<see cref="AIFunction.InvokeAsync(AIFunctionArguments, CancellationToken)"/>
/// so that downstream <see cref="FunctionResultContent"/> serialization is byte-identical to
/// a non-task-augmented MCP tool call. The agent's function-calling loop is unaware that a
/// task was used.
/// </para>
/// <para>
/// This wrapper is intended to be applied only to tools whose
/// <see cref="ToolExecution.TaskSupport"/> is <see cref="ToolTaskSupport.Required"/>
/// (selected by <see cref="McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync"/>).
/// As a defensive fallback, if the server still rejects the task-augmented call with
/// <see cref="McpErrorCode.MethodNotFound"/> (e.g. because tool-level capabilities changed
/// between <c>tools/list</c> and invocation), the wrapper transparently falls back to a
/// non-augmented call through the inner <see cref="McpClientTool"/>.
/// </para>
/// </remarks>
internal sealed class TaskAwareMcpClientAIFunction : AIFunction
{
private readonly McpClient _client;
private readonly McpClientTool _inner;
private readonly McpTaskOptions _options;
internal TaskAwareMcpClientAIFunction(McpClient client, McpClientTool inner, McpTaskOptions options)
{
_ = Throw.IfNull(client);
_ = Throw.IfNull(inner);
_ = Throw.IfNull(options);
this._client = client;
this._inner = inner;
this._options = options;
}
/// <inheritdoc />
public override string Name => this._inner.Name;
/// <inheritdoc />
public override string Description => this._inner.Description;
/// <inheritdoc />
public override JsonElement JsonSchema => this._inner.JsonSchema;
/// <inheritdoc />
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
/// <inheritdoc />
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
/// <inheritdoc />
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
_ = Throw.IfNull(arguments);
McpTaskMetadata? metadata = null;
if (this._options.DefaultTimeToLive is TimeSpan ttl)
{
metadata = new McpTaskMetadata { TimeToLive = ttl };
}
McpTask task;
try
{
task = await this._client.CallToolAsTaskAsync(
this._inner.Name,
arguments,
taskMetadata: metadata,
progress: null,
options: null,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound)
{
// Defensive fallback: the server's advertised TaskSupport indicated this tool
// could be invoked as a task, but the server now rejects task augmentation for it
// (e.g. capability changed between tools/list and invocation). Fall back to a
// non-augmented call through the inner McpClientTool.
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
return await this.PollAndRetrieveResultAsync(task.TaskId, cancellationToken).ConfigureAwait(false);
}
private async Task<JsonElement> PollAndRetrieveResultAsync(string taskId, CancellationToken cancellationToken)
{
try
{
McpTask terminal = await this._client.PollTaskUntilCompleteAsync(taskId, options: null, cancellationToken).ConfigureAwait(false);
return terminal.Status switch
{
McpTaskStatus.Completed => await this._client.GetTaskResultAsync(taskId, options: null, cancellationToken).ConfigureAwait(false),
McpTaskStatus.Cancelled => throw new OperationCanceledException(FormatTerminalStatusMessage(taskId, terminal)),
_ => throw new InvalidOperationException(FormatTerminalStatusMessage(taskId, terminal)),// Failed (or any future non-terminal-but-unhandled status that the poll loop returns).
};
}
catch (OperationCanceledException) when (this._options.CancelRemoteTaskOnLocalCancellation && cancellationToken.IsCancellationRequested)
{
await this.TryCancelTaskAsync(taskId).ConfigureAwait(false);
throw;
}
}
private static string FormatTerminalStatusMessage(string taskId, McpTask terminal)
=> string.IsNullOrEmpty(terminal.StatusMessage)
? $"MCP task '{taskId}' ended in terminal status '{terminal.Status}'."
: $"MCP task '{taskId}' ended in terminal status '{terminal.Status}': {terminal.StatusMessage}";
private async Task TryCancelTaskAsync(string taskId)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = await this._client.CancelTaskAsync(taskId, options: null, cts.Token).ConfigureAwait(false);
}
catch
{
// Best-effort cancellation; do not mask the original cancellation reason.
}
}
}
@@ -248,7 +248,7 @@ public sealed class DockerShellExecutor : ShellExecutor
/// Build the AIFunction for this tool.
/// </summary>
/// <remarks>
/// When <paramref name="requireApproval"/> is <see langword="true"/>
/// When <paramref name="requireApproval"/> is <see langword="null"/>
/// (the default), the returned function is wrapped in
/// <see cref="ApprovalRequiredAIFunction"/>. The caller must
/// explicitly pass <see langword="false"/> to opt out of approval
@@ -259,12 +259,14 @@ public sealed class DockerShellExecutor : ShellExecutor
/// <param name="name">Function name surfaced to the model.</param>
/// <param name="description">Function description for the model.</param>
/// <param name="requireApproval">
/// <see langword="true"/> (the default) wraps the function in
/// <see cref="ApprovalRequiredAIFunction"/>;
/// <see langword="true"/> or <see langword="null"/> (the default)
/// wraps the function in <see cref="ApprovalRequiredAIFunction"/>;
/// <see langword="false"/> opts out and returns the raw function.
/// </param>
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool? requireApproval = null)
{
var effectiveRequireApproval = requireApproval ?? true;
description ??=
"Execute a single shell command inside an isolated Docker container and return its " +
"stdout, stderr, and exit code. The container has no network, no host filesystem access " +
@@ -290,7 +292,7 @@ public sealed class DockerShellExecutor : ShellExecutor
},
new AIFunctionFactoryOptions { Name = name, Description = description });
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
return effectiveRequireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
}
/// <summary>
@@ -325,7 +325,7 @@ public sealed class LocalShellExecutor : ShellExecutor
/// container where the tool itself is the boundary).
/// </param>
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
{
if (!requireApproval && !this._acknowledgeUnsafe)
{
@@ -3,7 +3,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Tools.Shell;
@@ -66,20 +65,6 @@ public abstract class ShellExecutor : IAsyncDisposable
/// <param name="cancellationToken">Cancellation token.</param>
public abstract Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default);
/// <summary>
/// Build an <see cref="AIFunction"/> bound to this executor, suitable for
/// registering with an agent as a callable tool.
/// </summary>
/// <param name="name">Function name visible to the model.</param>
/// <param name="description">Function description for the model.</param>
/// <param name="requireApproval">
/// When <see langword="true"/> (the default), wraps the function in
/// <see cref="ApprovalRequiredAIFunction"/> so every invocation requires
/// explicit user approval before executing.
/// </param>
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
public abstract AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true);
/// <inheritdoc />
public abstract ValueTask DisposeAsync();
}
@@ -22,13 +22,9 @@ internal static class AgentProviderExtensions
{
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
// Determine whether the target conversation is the workflow conversation
// (used below to decide whether to mirror messages into the workflow conversation
// when an agent runs against a different conversation). The caller's autoSend
// value is honored as-is — when the workflow.yaml specifies autoSend: false the
// raw agent output must not be streamed to the caller, even when the agent is
// running on the workflow conversation.
// Enable "autoSend" behavior if this is the workflow conversation.
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
autoSend |= isWorkflowConversation;
// Process the agent response updates.
List<AgentResponseUpdate> updates = [];
@@ -71,13 +71,6 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Establish the Foundry ProductContext on the current async logical context before
// running any code that reads PropertyPath.VariableName / NamespaceAlias. ObjectModel
// resolves those lazily against AsyncLocal<ProductContext>; when the workflow is
// hosted (AsAIAgent + AddFoundryResponses) each HTTP request runs on a fresh logical
// context where the build-thread setting does not flow.
WorkflowDiagnostics.SetFoundryProduct();
if (this.Model.Disabled)
{
Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]");
@@ -192,16 +192,13 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
private bool GetAutoSendValue()
{
// AzureAgentOutput.AutoSend is never null — it returns a literal-false default
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
// explicit autoSend value from the implicit default, and treat the implicit
// default as autoSend = true (the historical behavior for actions that omit
// autoSend or have no output block at all).
if (this.AgentOutput is { AutoSendIsDefaultValue: false } output)
if (this.AgentOutput?.AutoSend is null)
{
return this.Evaluator.GetValue(output.AutoSend).Value;
return true;
}
return true;
EvaluationResult<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);
return autoSendResult.Value;
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -104,24 +103,6 @@ internal sealed class InvokeFunctionToolExecutor(
FunctionResultContent? matchingResult = functionResults
.FirstOrDefault(r => r.CallId == this.Id);
// When the caller approved an approval-required function call but didn't execute it
// locally (the hosted Foundry scenario, where mcp_approval_response is converted to a
// ToolApprovalResponseContent only), invoke the registered AIFunction here so that the
// declarative workflow can capture the result and continue (e.g. for downstream
// SendActivity/PropertyPath consumers like {Local.Result}).
if (matchingResult is null)
{
ToolApprovalResponseContent? approval = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalResponseContent>()
.FirstOrDefault(r => r.RequestId == this.Id);
if (approval is { Approved: true })
{
matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false);
}
}
if (matchingResult is not null)
{
// Store the result in output variable
@@ -260,48 +241,6 @@ internal sealed class InvokeFunctionToolExecutor(
return conversationIdValue.Length == 0 ? null : conversationIdValue;
}
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
{
string functionName = this.GetFunctionName();
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
if (function is null)
{
return new FunctionResultContent(this.Id, result: null)
{
Exception = new InvalidOperationException(
$"Function '{functionName}' is not registered with the agent provider."),
};
}
Dictionary<string, object?>? arguments = this.GetArguments();
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
object? result;
try
{
result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new FunctionResultContent(this.Id, result: null) { Exception = ex };
}
// Match FunctionInvokingChatClient's serialization: pass strings through as-is and
// JSON-serialize anything else so structured results remain consumable by downstream
// PropertyPath consumers such as {Local.RefundResult}. Use AIJsonUtilities so the
// same trim/AOT-friendly serializer chain used elsewhere in the framework is applied.
string serialized = result switch
{
null => string.Empty,
string s => s,
_ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())),
};
return new FunctionResultContent(this.Id, serialized);
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
@@ -314,16 +253,12 @@ internal sealed class InvokeFunctionToolExecutor(
private bool GetAutoSendValue()
{
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
// explicit autoSend value from the implicit default, and treat the implicit
// default as autoSend = true (the historical behavior).
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
if (this.Model.Output?.AutoSend is null)
{
return this.Evaluator.GetValue(output.AutoSend).Value;
return true;
}
return true;
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
}
private Dictionary<string, object?>? GetArguments()
@@ -311,16 +311,12 @@ internal sealed class InvokeMcpToolExecutor(
private bool GetAutoSendValue()
{
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
// explicit autoSend value from the implicit default, and treat the implicit
// default as autoSend = true (the historical behavior).
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
if (this.Model.Output?.AutoSend is null)
{
return this.Evaluator.GetValue(output.AutoSend).Value;
return true;
}
return true;
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
}
private string? GetConnectionName()

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