mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8c498d03 | ||
|
|
01a3c5be8a | ||
|
|
d74d26c917 | ||
|
|
72a6157c6a | ||
|
|
0ba552b84c | ||
|
|
dd1e615dad | ||
|
|
f390595188 | ||
|
|
4609535e22 |
@@ -17,7 +17,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -24,7 +24,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Node.js environment
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -37,7 +37,7 @@ runs:
|
||||
run: copilot --version && copilot -p "What can you do in one sentence?"
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
|
||||
@@ -44,9 +44,15 @@ updates:
|
||||
|
||||
# Maintain dependencies for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
# Workflow files stored in the
|
||||
# default location of `.github/workflows`
|
||||
directory: "/"
|
||||
# Cover both the standard workflow location and our composite actions.
|
||||
# With `directory: "/"` Dependabot only scans `.github/workflows/*.{yml,yaml}`
|
||||
# plus a root-level `action.yml/action.yaml`. It does NOT recurse into
|
||||
# `.github/actions/*/action.yml`, so the glob below is required to keep the
|
||||
# composite actions in `.github/actions/<name>/` up to date as well.
|
||||
# Ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#directories-or-directory--
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/*"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "sunday"
|
||||
|
||||
@@ -32,13 +32,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -64,6 +64,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
# Safe checkout: base repo only, not the untrusted PR head.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -135,12 +135,12 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
- name: Build dotnet solutions
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
# Generate test reports and check coverage
|
||||
- name: Generate test reports
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@2a82782178b2816d9d6960a7345fdd164791b323 # 5.5.3
|
||||
with:
|
||||
reports: "./TestResults/Coverage/**/*.cobertura.xml"
|
||||
targetdir: "./TestResults/Reports"
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage report artifact
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
|
||||
path: ./TestResults/Reports # Directory containing files to upload
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
|
||||
- name: Upload integration test results
|
||||
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
env:
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -366,7 +366,7 @@ jobs:
|
||||
python
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -381,7 +381,7 @@ jobs:
|
||||
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -453,7 +453,7 @@ jobs:
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -465,7 +465,7 @@ jobs:
|
||||
dotnet build ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests -c Release -f net10.0 --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
|
||||
- name: Upload functions test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dotnet-test-results-functions-net10.0-ubuntu-latest
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
@@ -560,14 +560,14 @@ jobs:
|
||||
- name: Fail workflow if tests failed
|
||||
id: check_tests_failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
id: check_tests_cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -585,7 +585,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -597,12 +597,12 @@ jobs:
|
||||
python-version: "3.13"
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
@@ -619,13 +619,13 @@ jobs:
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
- name: Upload trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dotnet-integration-test-report
|
||||
path: |
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: jitterbit/get-changed-files@v1
|
||||
uses: jitterbit/get-changed-files@b17fbb00bdc0c0f63fcf166580804b4d2cdc2a42 # v1
|
||||
continue-on-error: true
|
||||
|
||||
- name: No C# files changed
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
environment: 'integration'
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -52,13 +52,13 @@ jobs:
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: verify-samples-results
|
||||
path: |
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Check issue author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
steps:
|
||||
# Safe checkout: base repo only.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
# Private DevFlow (maf-dashboard) checkout.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
|
||||
@@ -16,6 +16,6 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
with:
|
||||
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
name: "Issue/PR: update title"
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -19,13 +19,13 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
# check out the latest version of the code
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Checks the status of hyperlinks in all files
|
||||
- name: Run linkspector
|
||||
uses: umbrelladocs/action-linkspector@v1
|
||||
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
|
||||
with:
|
||||
reporter: local
|
||||
filter_mode: nofilter
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- name: Wait for required checks
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TIMEOUT_SECONDS: "3600"
|
||||
INTERVAL_SECONDS: "30"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/prek
|
||||
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
|
||||
- uses: j178/prek-action@v1
|
||||
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1
|
||||
name: Run Pre-commit Hooks (excluding poe-check)
|
||||
env:
|
||||
SKIP: poe-check
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Upload dependency range report
|
||||
# Always publish the report so failures are inspectable even when validation fails.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dependency-range-results
|
||||
path: python/scripts/dependencies/dependency-range-results.json
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Create issues for failed dependency candidates
|
||||
# Always process the report so failed candidates create actionable tracking issues.
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -388,7 +388,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -399,7 +399,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -443,7 +443,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
@@ -496,7 +496,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -506,12 +506,12 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
@@ -528,13 +528,13 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -558,12 +558,12 @@ jobs:
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
# Surface failing tests
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/packages/lab/**.xml
|
||||
summary: true
|
||||
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
title: OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
title: Azure OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -370,7 +370,7 @@ jobs:
|
||||
kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -379,7 +379,7 @@ jobs:
|
||||
title: Misc integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -448,7 +448,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -457,7 +457,7 @@ jobs:
|
||||
title: Functions integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
@@ -488,7 +488,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -497,7 +497,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -515,7 +515,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -524,7 +524,7 @@ jobs:
|
||||
title: Test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -549,7 +549,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -558,7 +558,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -576,7 +576,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -585,7 +585,7 @@ jobs:
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -620,7 +620,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -643,7 +643,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -652,7 +652,7 @@ jobs:
|
||||
title: Cosmos integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
@@ -680,19 +680,19 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
@@ -709,13 +709,13 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -740,13 +740,13 @@ jobs:
|
||||
- name: Fail workflow if tests failed
|
||||
id: check_tests_failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
id: check_tests_cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Build the package
|
||||
run: uv run poe --directory packages/${{ env.PACKAGE }} build
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: |
|
||||
python/dist/*
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-01-get-started
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-openai
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure --save-report --report-name 02-agents-azure
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-azure
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-anthropic
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-github-copilot
|
||||
@@ -274,7 +274,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-amazon
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -321,7 +321,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-ollama
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -363,7 +363,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry --save-report --report-name 02-agents-foundry
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-foundry
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-copilotstudio
|
||||
@@ -419,7 +419,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-custom
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -471,7 +471,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-03-workflows
|
||||
@@ -491,7 +491,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -506,7 +506,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting
|
||||
@@ -534,7 +534,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -549,7 +549,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-05-end-to-end
|
||||
@@ -574,7 +574,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -599,7 +599,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-autogen-migration
|
||||
@@ -633,7 +633,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -662,7 +662,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-semantic-kernel-migration
|
||||
@@ -690,10 +690,10 @@ jobs:
|
||||
- validate-autogen-migration
|
||||
- validate-semantic-kernel-migration
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Download all validation reports
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: validation-report-*
|
||||
path: reports/
|
||||
@@ -701,7 +701,7 @@ jobs:
|
||||
|
||||
- name: Restore validation history
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
@@ -719,13 +719,13 @@ jobs:
|
||||
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save validation history
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
|
||||
- name: Upload trend report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-trend-report
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
||||
- name: Pytest coverage comment
|
||||
id: coverageComment
|
||||
uses: MishaKav/pytest-coverage-comment@v1.6.0
|
||||
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
# in the coverage report workflow does not have access to it
|
||||
- name: Save PR number
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Check coverage threshold
|
||||
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
path: |
|
||||
python/python-coverage.xml
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
# Surface failing tests
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
|
||||
@@ -28,9 +28,7 @@ public sealed class A2AAgent : AIAgent
|
||||
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
|
||||
|
||||
private readonly IA2AClient _a2aClient;
|
||||
private readonly string? _id;
|
||||
private readonly string? _name;
|
||||
private readonly string? _description;
|
||||
private readonly A2AAgentOptions _agentOptions;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -38,17 +36,37 @@ public sealed class A2AAgent : AIAgent
|
||||
/// </summary>
|
||||
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
|
||||
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
|
||||
: this(
|
||||
a2aClient,
|
||||
new A2AAgentOptions
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = description
|
||||
},
|
||||
loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="A2AAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
|
||||
/// <param name="options">
|
||||
/// Configuration options that control the agent's identity, including its identifier, name, and description.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
|
||||
public A2AAgent(IA2AClient a2aClient, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(a2aClient);
|
||||
_ = Throw.IfNull(options);
|
||||
|
||||
this._a2aClient = a2aClient;
|
||||
this._id = id;
|
||||
this._name = name;
|
||||
this._description = description;
|
||||
this._agentOptions = options.Clone();
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
|
||||
}
|
||||
|
||||
@@ -216,13 +234,13 @@ public sealed class A2AAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string? IdCore => this._id;
|
||||
protected override string? IdCore => this._agentOptions.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._name;
|
||||
public override string? Name => this._agentOptions.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Description => this._description;
|
||||
public override string? Description => this._agentOptions.Description;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Represents configuration options for an <see cref="A2AAgent"/>, including its identifier, name, and description.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class is used to encapsulate information about an A2A agent, such as its unique
|
||||
/// identifier, display name, and a descriptive summary. It provides an alternative to passing
|
||||
/// these values as individual constructor parameters.
|
||||
/// </remarks>
|
||||
public sealed class A2AAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the agent id.
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent name.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="A2AAgentOptions"/> with the same values as this instance.
|
||||
/// </summary>
|
||||
public A2AAgentOptions Clone()
|
||||
=> new()
|
||||
{
|
||||
Id = this.Id,
|
||||
Name = this.Name,
|
||||
Description = this.Description
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
using System.Net.Http;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
@@ -36,4 +38,39 @@ public static class A2AAgentCardExtensions
|
||||
|
||||
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
|
||||
/// the corresponding values from the <see cref="AgentCard"/>.
|
||||
/// </remarks>
|
||||
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
|
||||
/// <param name="agentOptions">
|
||||
/// Configuration options that control the agent's identity. When provided, non-null values override the
|
||||
/// corresponding values from the agent card.
|
||||
/// </param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this AgentCard card, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(card);
|
||||
_ = Throw.IfNull(agentOptions);
|
||||
|
||||
var a2aClient = A2AClientFactory.Create(card, httpClient, clientOptions);
|
||||
|
||||
var mergedOptions = agentOptions.Clone();
|
||||
mergedOptions.Name ??= card.Name;
|
||||
mergedOptions.Description ??= card.Description;
|
||||
|
||||
return a2aClient.AsAIAgent(mergedOptions, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
@@ -48,4 +49,39 @@ public static class A2ACardResolverExtensions
|
||||
|
||||
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see>
|
||||
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
|
||||
/// the corresponding values from the resolved <see cref="AgentCard"/>.
|
||||
/// </remarks>
|
||||
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
|
||||
/// <param name="agentOptions">
|
||||
/// Configuration options that control the agent's identity. When provided, non-null values override the
|
||||
/// corresponding values from the resolved agent card.
|
||||
/// </param>
|
||||
/// <param name="httpClient">
|
||||
/// The <see cref="HttpClient"/> to use for HTTP requests made by the created A2A client.
|
||||
/// This is not used for fetching the agent card; the resolver uses its own configured client for that.
|
||||
/// </param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(agentOptions);
|
||||
|
||||
// Obtain the agent card from the resolver.
|
||||
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return agentCard.AsAIAgent(agentOptions, httpClient, clientOptions, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
@@ -31,10 +32,32 @@ public static class A2AClientExtensions
|
||||
/// </remarks>
|
||||
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
|
||||
new A2AAgent(client, id, name, description, loggerFactory);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
|
||||
/// <param name="options">
|
||||
/// Configuration options that control the agent's identity, including its identifier, name, and description.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this IA2AClient client, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(client);
|
||||
_ = Throw.IfNull(options);
|
||||
|
||||
return new A2AAgent(client, options, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,72 @@ public sealed class A2AAgentTests : IDisposable
|
||||
Assert.Null(agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithOptions_InitializesPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var options = new A2AAgentOptions
|
||||
{
|
||||
Id = "options-id",
|
||||
Name = "options-name",
|
||||
Description = "options-description"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new A2AAgent(this._a2aClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("options-id", agent.Id);
|
||||
Assert.Equal("options-name", agent.Name);
|
||||
Assert.Equal("options-description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithOptions_IsolatesAgentFromOptionsMutation()
|
||||
{
|
||||
// Arrange
|
||||
var options = new A2AAgentOptions
|
||||
{
|
||||
Id = "original-id",
|
||||
Name = "Original Name",
|
||||
Description = "Original Description"
|
||||
};
|
||||
var agent = new A2AAgent(this._a2aClient, options);
|
||||
|
||||
// Act - mutate options after agent construction
|
||||
options.Id = "mutated-id";
|
||||
options.Name = "Mutated Name";
|
||||
options.Description = "Mutated Description";
|
||||
|
||||
// Assert - agent should retain original values
|
||||
Assert.Equal("original-id", agent.Id);
|
||||
Assert.Equal("Original Name", agent.Name);
|
||||
Assert.Equal("Original Description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullOptions_ThrowsArgumentNullException() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new A2AAgent(this._a2aClient, options: null!));
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithEmptyOptions_UsesBaseProperties()
|
||||
{
|
||||
// Act
|
||||
var agent = new A2AAgent(this._a2aClient, new A2AAgentOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.Id);
|
||||
Assert.NotEmpty(agent.Id);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithOptions_NullA2AClient_ThrowsArgumentNullException() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new A2AAgent(null!, new A2AAgentOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AllowsNonUserRoleMessagesAsync()
|
||||
{
|
||||
|
||||
+76
-1
@@ -31,7 +31,7 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_ReturnsAIAgent()
|
||||
public void AsAIAgent_ReturnsAIAgent()
|
||||
{
|
||||
// Act
|
||||
var agent = this._agentCard.AsAIAgent();
|
||||
@@ -165,6 +165,81 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentOptions_OverridesCardValues()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id",
|
||||
Name = "Custom Agent",
|
||||
Description = "Custom description"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = card.AsAIAgent(agentOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Custom Agent", agent.Name);
|
||||
Assert.Equal("Custom description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentOptions_FallsBackToCardValues()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = card.AsAIAgent(agentOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Card Agent", agent.Name);
|
||||
Assert.Equal("Card description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithEmptyAgentOptions_UsesCardValues()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = card.AsAIAgent(new A2AAgentOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Card Agent", agent.Name);
|
||||
Assert.Equal("Card description", agent.Description);
|
||||
}
|
||||
|
||||
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public Queue ResponsesToReturn { get; } = new();
|
||||
|
||||
+55
@@ -113,6 +113,61 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithAgentOptions_OverridesCardValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id",
|
||||
Name = "Custom Agent",
|
||||
Description = "Custom description"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Custom Agent", agent.Name);
|
||||
Assert.Equal("Custom description", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithAgentOptions_FallsBackToCardValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Card Agent",
|
||||
Description = "Card description",
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
|
||||
var agentOptions = new A2AAgentOptions
|
||||
{
|
||||
Id = "custom-id"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("custom-id", agent.Id);
|
||||
Assert.Equal("Card Agent", agent.Name);
|
||||
Assert.Equal("Card description", agent.Description);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
|
||||
+44
-3
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
public sealed class A2AClientExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
public void AsAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
@@ -32,7 +32,7 @@ public sealed class A2AClientExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
public void AsAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange - use IA2AClient reference type to verify the extension method works with the interface
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
@@ -53,7 +53,7 @@ public sealed class A2AClientExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
|
||||
public void AsAIAgent_WithIA2AClient_ExposesClientViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
@@ -66,4 +66,45 @@ public sealed class A2AClientExtensionsTests
|
||||
Assert.NotNull(service);
|
||||
Assert.Same(a2aClient, service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithOptions_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
var options = new A2AAgentOptions
|
||||
{
|
||||
Id = "options-agent-id",
|
||||
Name = "Options Agent",
|
||||
Description = "Agent created with options"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("options-agent-id", agent.Id);
|
||||
Assert.Equal("Options Agent", agent.Name);
|
||||
Assert.Equal("Agent created with options", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithEmptyOptions_ReturnsA2AAgentWithDefaultProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent(new A2AAgentOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.NotNull(agent.Id);
|
||||
Assert.NotEmpty(agent.Id);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -44,7 +44,6 @@ GEMINI_MODEL=""
|
||||
# Ollama
|
||||
OLLAMA_ENDPOINT=""
|
||||
OLLAMA_MODEL=""
|
||||
# Observability
|
||||
ENABLE_INSTRUMENTATION=true
|
||||
# Observability (instrumentation is enabled by default; set "ENABLE_INSTRUMENTATION" to "false" to opt out)
|
||||
ENABLE_SENSITIVE_DATA=true
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ def equal(arg1: str, arg2: str) -> bool:
|
||||
from agent_framework import Agent, Message, tool
|
||||
|
||||
# Components
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
|
||||
# Connectors (lazy-loaded)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
@@ -93,3 +93,4 @@ python/
|
||||
|
||||
### Experimental
|
||||
- [lab](packages/lab/AGENTS.md) - Experimental features
|
||||
- [monty](packages/monty/AGENTS.md) - Monty-backed CodeAct integrations (alpha)
|
||||
|
||||
@@ -186,7 +186,7 @@ The package follows a flat import structure:
|
||||
|
||||
- **Components**: Import from `agent_framework.<component>`
|
||||
```python
|
||||
from agent_framework.observability import enable_instrumentation, configure_otel_providers
|
||||
from agent_framework.observability import enable_sensitive_telemetry, configure_otel_providers
|
||||
```
|
||||
|
||||
- **Connectors**: Import from `agent_framework.<vendor/platform>`
|
||||
|
||||
@@ -37,6 +37,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
| `agent-framework-monty` | `python/packages/monty` | `alpha` |
|
||||
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
|
||||
| `agent-framework-openai` | `python/packages/openai` | `released` |
|
||||
| `agent-framework-orchestrations` | `python/packages/orchestrations` | `beta` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.0.0rc2"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
|
||||
from agent_framework import (
|
||||
@@ -31,11 +30,6 @@ from chatkit.types import (
|
||||
WorkflowItem,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import assert_never # type:ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import assert_never # type:ignore # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -532,7 +526,10 @@ class ThreadItemConverter:
|
||||
# TODO(evmattso): Implement structured input handling in a future PR
|
||||
return []
|
||||
case _:
|
||||
assert_never(item)
|
||||
# Unknown ThreadItem variant (e.g. types added in newer chatkit versions).
|
||||
# Skip rather than fail so we remain forward-compatible with chatkit upgrades.
|
||||
logger.debug("Skipping unsupported ThreadItem of type %s", type(item).__name__)
|
||||
return []
|
||||
|
||||
async def to_agent_input(
|
||||
self,
|
||||
|
||||
@@ -255,7 +255,7 @@ class MCPTool:
|
||||
self._exit_stack = AsyncExitStack()
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
self._lifecycle_request_lock = asyncio.Lock()
|
||||
self._lifecycle_queue: asyncio.Queue[tuple[str, bool, asyncio.Future[None]]] | None = None
|
||||
self._lifecycle_queue: asyncio.Queue[tuple[str, bool, bool, asyncio.Future[None]]] | None = None
|
||||
self._lifecycle_owner_task: asyncio.Task[None] | None = None
|
||||
self.session = session
|
||||
self.request_timeout = request_timeout
|
||||
@@ -265,6 +265,11 @@ class MCPTool:
|
||||
self.is_connected: bool = False
|
||||
self._tools_loaded: bool = False
|
||||
self._prompts_loaded: bool = False
|
||||
self._server_capabilities: types.ServerCapabilities | None = None
|
||||
self._supports_tools: bool = True
|
||||
self._supports_prompts: bool = True
|
||||
self._supports_logging: bool | None = None
|
||||
self._ping_available: bool = True
|
||||
self._pending_reload_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -566,11 +571,11 @@ class MCPTool:
|
||||
stop_error: BaseException | None = None
|
||||
try:
|
||||
while True:
|
||||
action, reset, future = await queue.get()
|
||||
action, reset, load_configured, future = await queue.get()
|
||||
|
||||
try:
|
||||
if action == "connect":
|
||||
await self._connect_on_owner(reset=reset)
|
||||
await self._connect_on_owner(reset=reset, load_configured=load_configured)
|
||||
elif action == "close":
|
||||
await self._close_on_owner()
|
||||
else:
|
||||
@@ -595,7 +600,7 @@ class MCPTool:
|
||||
finally:
|
||||
while True:
|
||||
try:
|
||||
_, _, future = queue.get_nowait()
|
||||
_, _, _, future = queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if not future.done():
|
||||
@@ -608,12 +613,18 @@ class MCPTool:
|
||||
owner_task = self._lifecycle_owner_task
|
||||
return owner_task is not None and asyncio.current_task() is owner_task
|
||||
|
||||
async def _run_on_lifecycle_owner(self, action: str, *, reset: bool = False) -> None:
|
||||
async def _run_on_lifecycle_owner(
|
||||
self,
|
||||
action: str,
|
||||
*,
|
||||
reset: bool = False,
|
||||
load_configured: bool = True,
|
||||
) -> None:
|
||||
await self._ensure_lifecycle_owner()
|
||||
|
||||
if self._is_lifecycle_owner_task():
|
||||
if action == "connect":
|
||||
await self._connect_on_owner(reset=reset)
|
||||
await self._connect_on_owner(reset=reset, load_configured=load_configured)
|
||||
elif action == "close":
|
||||
await self._close_on_owner()
|
||||
else:
|
||||
@@ -625,7 +636,7 @@ class MCPTool:
|
||||
raise RuntimeError("MCP lifecycle owner is not available.")
|
||||
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
await queue.put((action, reset, future))
|
||||
await queue.put((action, reset, load_configured, future))
|
||||
await future
|
||||
|
||||
async def _safe_close_exit_stack(self) -> None:
|
||||
@@ -656,6 +667,32 @@ class MCPTool:
|
||||
await self._safe_close_exit_stack()
|
||||
return _should_propagate_cancelled_error(ex)
|
||||
|
||||
def _reset_session_state(self) -> None:
|
||||
self._server_capabilities = None
|
||||
self._supports_tools = True
|
||||
self._supports_prompts = True
|
||||
self._supports_logging = None
|
||||
self._ping_available = True
|
||||
|
||||
def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None:
|
||||
self._server_capabilities = capabilities
|
||||
if capabilities is None:
|
||||
self._supports_tools = False
|
||||
self._supports_prompts = False
|
||||
self._supports_logging = False
|
||||
return
|
||||
|
||||
self._supports_tools = getattr(capabilities, "tools", None) is not None
|
||||
self._supports_prompts = getattr(capabilities, "prompts", None) is not None
|
||||
self._supports_logging = getattr(capabilities, "logging", None) is not None
|
||||
|
||||
async def _reconnect_without_loading(self) -> None:
|
||||
if self._is_lifecycle_owner_task():
|
||||
await self._connect_on_owner(reset=True, load_configured=False)
|
||||
return
|
||||
|
||||
await self._run_on_lifecycle_owner("connect", reset=True, load_configured=False)
|
||||
|
||||
async def connect(self, *, reset: bool = False) -> None:
|
||||
if self._is_lifecycle_owner_task():
|
||||
await self._connect_on_owner(reset=reset)
|
||||
@@ -664,7 +701,7 @@ class MCPTool:
|
||||
async with self._lifecycle_request_lock:
|
||||
await self._run_on_lifecycle_owner("connect", reset=reset)
|
||||
|
||||
async def _connect_on_owner(self, *, reset: bool = False) -> None:
|
||||
async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool = True) -> None:
|
||||
"""Connect to the MCP server.
|
||||
|
||||
Establishes a connection to the MCP server, initializes the session,
|
||||
@@ -672,6 +709,7 @@ class MCPTool:
|
||||
|
||||
Keyword Args:
|
||||
reset: If True, forces a reconnection even if already connected.
|
||||
load_configured: If True, loads tools and prompts according to the constructor flags.
|
||||
|
||||
Raises:
|
||||
ToolException: If connection or session initialization fails.
|
||||
@@ -680,6 +718,7 @@ class MCPTool:
|
||||
await self._safe_close_exit_stack()
|
||||
self.session = None
|
||||
self.is_connected = False
|
||||
self._reset_session_state()
|
||||
self._exit_stack = AsyncExitStack()
|
||||
if not self.session:
|
||||
try:
|
||||
@@ -741,7 +780,8 @@ class MCPTool:
|
||||
inner_exception=ex if isinstance(ex, Exception) else None,
|
||||
) from ex
|
||||
try:
|
||||
await session.initialize()
|
||||
initialize_result = await session.initialize()
|
||||
self._set_server_capabilities(getattr(initialize_result, "capabilities", None))
|
||||
except (Exception, asyncio.CancelledError) as ex:
|
||||
if await self._close_and_check_cancelled(ex):
|
||||
raise
|
||||
@@ -759,17 +799,22 @@ class MCPTool:
|
||||
self.session = session
|
||||
elif self.session._request_id == 0: # type: ignore[attr-defined]
|
||||
# If the session is not initialized, we need to reinitialize it
|
||||
await self.session.initialize()
|
||||
initialize_result = await self.session.initialize()
|
||||
self._set_server_capabilities(getattr(initialize_result, "capabilities", None))
|
||||
elif self._server_capabilities is None:
|
||||
self._set_server_capabilities(getattr(self.session, "_server_capabilities", None))
|
||||
logger.debug("Connected to MCP server: %s", self.session)
|
||||
self.is_connected = True
|
||||
if self.load_tools_flag:
|
||||
await self.load_tools()
|
||||
if load_configured and self.load_tools_flag:
|
||||
if self._supports_tools:
|
||||
await self.load_tools()
|
||||
self._tools_loaded = True
|
||||
if self.load_prompts_flag:
|
||||
await self.load_prompts()
|
||||
if load_configured and self.load_prompts_flag:
|
||||
if self._supports_prompts:
|
||||
await self.load_prompts()
|
||||
self._prompts_loaded = True
|
||||
|
||||
if logger.level != logging.NOTSET:
|
||||
if logger.level != logging.NOTSET and self._supports_logging is not False:
|
||||
try:
|
||||
level_name = cast(
|
||||
Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
|
||||
@@ -973,17 +1018,49 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import types
|
||||
|
||||
if not self._supports_prompts:
|
||||
logger.debug("Skipping MCP prompt loading because the server did not advertise prompts support.")
|
||||
return
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
|
||||
params: types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
prompt_list: types.ListPromptsResult | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
if not self._supports_prompts:
|
||||
logger.debug(
|
||||
"Skipping MCP prompt loading because the server did not advertise prompts support."
|
||||
)
|
||||
return
|
||||
prompt_list = await self.session.list_prompts(params=params) # type: ignore[union-attr]
|
||||
break
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
logger.info("MCP connection closed unexpectedly while loading prompts. Reconnecting...")
|
||||
try:
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as reconn_ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
continue
|
||||
logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex)
|
||||
raise ToolExecutionException(
|
||||
"Failed to load prompts - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
|
||||
prompt_list = await self.session.list_prompts(params=params) # type: ignore[union-attr]
|
||||
if prompt_list is None:
|
||||
raise ToolExecutionException("Failed to load prompts.")
|
||||
|
||||
for prompt in prompt_list.prompts:
|
||||
normalized_name = _normalize_mcp_name(prompt.name)
|
||||
@@ -1010,7 +1087,7 @@ class MCPTool:
|
||||
existing_names.add(local_name)
|
||||
|
||||
# Check if there are more pages
|
||||
if not prompt_list or not prompt_list.nextCursor:
|
||||
if not prompt_list.nextCursor:
|
||||
break
|
||||
params = types.PaginatedRequestParams(cursor=prompt_list.nextCursor)
|
||||
|
||||
@@ -1023,18 +1100,48 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import types
|
||||
|
||||
if not self._supports_tools:
|
||||
logger.debug("Skipping MCP tool loading because the server did not advertise tools support.")
|
||||
return
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
self._tool_call_meta_by_name.clear()
|
||||
|
||||
params: types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
tool_list: types.ListToolsResult | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
# Ensure connection is still valid before each page request
|
||||
await self._ensure_connected()
|
||||
if not self._supports_tools:
|
||||
logger.debug("Skipping MCP tool loading because the server did not advertise tools support.")
|
||||
return
|
||||
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
|
||||
break
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
logger.info("MCP connection closed unexpectedly while loading tools. Reconnecting...")
|
||||
try:
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as reconn_ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
continue
|
||||
logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex)
|
||||
raise ToolExecutionException(
|
||||
"Failed to load tools - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
|
||||
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
|
||||
if tool_list is None:
|
||||
raise ToolExecutionException("Failed to load tools.")
|
||||
|
||||
for tool in tool_list.tools:
|
||||
if tool.meta is not None:
|
||||
@@ -1083,7 +1190,7 @@ class MCPTool:
|
||||
existing_names.add(local_name)
|
||||
|
||||
# Check if there are more pages
|
||||
if not tool_list or not tool_list.nextCursor:
|
||||
if not tool_list.nextCursor:
|
||||
break
|
||||
params = types.PaginatedRequestParams(cursor=tool_list.nextCursor)
|
||||
|
||||
@@ -1100,6 +1207,7 @@ class MCPTool:
|
||||
self._exit_stack = AsyncExitStack()
|
||||
self.session = None
|
||||
self.is_connected = False
|
||||
self._reset_session_state()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Disconnect from the MCP server.
|
||||
@@ -1131,12 +1239,30 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If reconnection fails.
|
||||
"""
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
if not self._ping_available:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.session.send_ping() # type: ignore[union-attr]
|
||||
except McpError as mcp_exc:
|
||||
if mcp_exc.error.code == -32601:
|
||||
self._ping_available = False
|
||||
logger.debug("Skipping future MCP pings because the server does not support ping.")
|
||||
return
|
||||
logger.info("MCP connection invalid or closed. Reconnecting...")
|
||||
try:
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to establish MCP connection.",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
except Exception:
|
||||
logger.info("MCP connection invalid or closed. Reconnecting...")
|
||||
try:
|
||||
await self.connect(reset=True)
|
||||
await self._reconnect_without_loading()
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to establish MCP connection.",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Commonly used exports:
|
||||
- enable_instrumentation
|
||||
- disable_instrumentation
|
||||
- enable_sensitive_telemetry
|
||||
- configure_otel_providers
|
||||
- AgentTelemetryLayer
|
||||
- ChatTelemetryLayer
|
||||
@@ -80,7 +82,9 @@ __all__ = [
|
||||
"configure_otel_providers",
|
||||
"create_metric_views",
|
||||
"create_resource",
|
||||
"disable_instrumentation",
|
||||
"enable_instrumentation",
|
||||
"enable_sensitive_telemetry",
|
||||
"get_meter",
|
||||
"get_tracer",
|
||||
]
|
||||
@@ -643,8 +647,8 @@ class ObservabilitySettings:
|
||||
Sensitive events should only be enabled on test and development environments.
|
||||
|
||||
Keyword Args:
|
||||
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is False.
|
||||
Can be set via environment variable ENABLE_INSTRUMENTATION.
|
||||
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is True.
|
||||
Can be disabled by setting environment variable ENABLE_INSTRUMENTATION=false.
|
||||
enable_sensitive_data: Enable OpenTelemetry sensitive events. Default is False.
|
||||
Can be set via environment variable ENABLE_SENSITIVE_DATA.
|
||||
enable_console_exporters: Enable console exporters for traces, logs, and metrics.
|
||||
@@ -659,12 +663,12 @@ class ObservabilitySettings:
|
||||
from agent_framework import ObservabilitySettings
|
||||
|
||||
# Using environment variables
|
||||
# Set ENABLE_INSTRUMENTATION=true
|
||||
# Instrumentation is enabled by default; set ENABLE_INSTRUMENTATION=false to disable.
|
||||
# Set ENABLE_CONSOLE_EXPORTERS=true
|
||||
settings = ObservabilitySettings()
|
||||
|
||||
# Or passing parameters directly
|
||||
settings = ObservabilitySettings(enable_instrumentation=True, enable_console_exporters=True)
|
||||
settings = ObservabilitySettings(enable_console_exporters=True)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
@@ -677,14 +681,74 @@ class ObservabilitySettings:
|
||||
env_file_encoding=env_file_encoding,
|
||||
**kwargs,
|
||||
)
|
||||
self.enable_instrumentation: bool = data.get("enable_instrumentation") or False
|
||||
self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
|
||||
# Sticky-disable flag, set by `disable_instrumentation()`. When True, this
|
||||
# singleton refuses to be re-enabled by any subsequent assignment to the
|
||||
# `enable_instrumentation` / `enable_sensitive_data` properties (including
|
||||
# direct third-party writes). It can only be cleared by an explicit
|
||||
# `enable_instrumentation(force=True)` / `enable_sensitive_telemetry(force=True)`
|
||||
# call, which is the user re-stating their intent.
|
||||
self._user_disabled: bool = False
|
||||
# `enable_instrumentation` is defaulted to True if not set
|
||||
instrumentation_value = data.get("enable_instrumentation")
|
||||
self._enable_instrumentation: bool = True if instrumentation_value is None else instrumentation_value
|
||||
self._enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
|
||||
if self._enable_sensitive_data and not self._enable_instrumentation:
|
||||
logger.warning(
|
||||
"Sensitive data capture is enabled but instrumentation is disabled. "
|
||||
"Sensitive data will not be captured. Please enable instrumentation to capture sensitive data."
|
||||
)
|
||||
|
||||
self.enable_console_exporters: bool = data.get("enable_console_exporters") or False
|
||||
self.vs_code_extension_port: int | None = data.get("vs_code_extension_port")
|
||||
self.env_file_path = env_file_path
|
||||
self.env_file_encoding = env_file_encoding
|
||||
self._executed_setup = False
|
||||
|
||||
@property
|
||||
def enable_instrumentation(self) -> bool:
|
||||
"""Whether instrumentation is enabled.
|
||||
|
||||
Always returns False once ``disable_instrumentation()`` has been called,
|
||||
regardless of the stored value, until ``enable_instrumentation(force=True)``
|
||||
clears the sticky disable.
|
||||
"""
|
||||
if self._user_disabled:
|
||||
return False
|
||||
return self._enable_instrumentation
|
||||
|
||||
@enable_instrumentation.setter
|
||||
def enable_instrumentation(self, value: bool) -> None:
|
||||
if self._user_disabled and value:
|
||||
# Defense in depth: a third-party (or internal) write of True is
|
||||
# silently dropped while the user-disabled flag is set, so the
|
||||
# sticky disable cannot be circumvented by direct attribute writes.
|
||||
logger.debug(
|
||||
"Ignoring enable_instrumentation=True assignment: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Call enable_instrumentation(force=True) to clear the disable."
|
||||
)
|
||||
return
|
||||
self._enable_instrumentation = value
|
||||
|
||||
@property
|
||||
def enable_sensitive_data(self) -> bool:
|
||||
"""Whether sensitive-data capture is enabled.
|
||||
|
||||
Always returns False once ``disable_instrumentation()`` has been called.
|
||||
"""
|
||||
if self._user_disabled:
|
||||
return False
|
||||
return self._enable_sensitive_data
|
||||
|
||||
@enable_sensitive_data.setter
|
||||
def enable_sensitive_data(self, value: bool) -> None:
|
||||
if self._user_disabled and value:
|
||||
logger.debug(
|
||||
"Ignoring enable_sensitive_data=True assignment: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Call enable_sensitive_telemetry(force=True) to clear the disable."
|
||||
)
|
||||
return
|
||||
self._enable_sensitive_data = value
|
||||
|
||||
@property
|
||||
def ENABLED(self) -> bool:
|
||||
"""Check if model diagnostics are enabled.
|
||||
@@ -706,6 +770,17 @@ class ObservabilitySettings:
|
||||
"""Check if the setup has been executed."""
|
||||
return self._executed_setup
|
||||
|
||||
@property
|
||||
def is_user_disabled(self) -> bool:
|
||||
"""Whether ``disable_instrumentation()`` has been called and the disable is still in effect.
|
||||
|
||||
Integrations that perform telemetry setup as a side-effect (e.g. provisioning Azure Monitor
|
||||
providers from a Foundry project's connection string) should consult this flag before doing
|
||||
their setup work, so the user's explicit opt-out is respected end-to-end and not just at the
|
||||
framework's span-emission boundary.
|
||||
"""
|
||||
return self._user_disabled
|
||||
|
||||
def _configure(
|
||||
self,
|
||||
*,
|
||||
@@ -951,24 +1026,91 @@ def _read_int_env(name: str, *, default: int | None = None) -> int | None:
|
||||
return default
|
||||
|
||||
|
||||
def enable_sensitive_telemetry(*, force: bool = False) -> None:
|
||||
"""Enable capture of sensitive data in telemetry for your application.
|
||||
|
||||
Instrumentation is enabled by default; this method exists to opt-in to capturing
|
||||
sensitive event payloads (e.g., chat messages, tool arguments).
|
||||
|
||||
This method does not configure exporters or providers. It also ensures that
|
||||
instrumentation is enabled (in case it was explicitly disabled via the
|
||||
ENABLE_INSTRUMENTATION environment variable).
|
||||
|
||||
Keyword Args:
|
||||
force: When True, clears any sticky disable previously set by
|
||||
``disable_instrumentation()`` before enabling. Without it, calls are
|
||||
no-ops if instrumentation has been explicitly disabled.
|
||||
|
||||
Warning:
|
||||
Sensitive events should only be enabled on test and development environments.
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
|
||||
logger.info(
|
||||
"enable_sensitive_telemetry() ignored: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Pass force=True to re-enable."
|
||||
)
|
||||
return
|
||||
if force:
|
||||
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = True
|
||||
|
||||
|
||||
def disable_instrumentation() -> None:
|
||||
"""Explicitly disable Agent Framework instrumentation for this process.
|
||||
|
||||
The disable is **sticky**: subsequent attempts by framework auto-setup paths,
|
||||
library integrations, ``enable_instrumentation()``, ``enable_sensitive_telemetry()``,
|
||||
``configure_otel_providers()``, or direct writes to
|
||||
``OBSERVABILITY_SETTINGS.enable_instrumentation`` are ignored and no spans, metrics,
|
||||
or logs are emitted by Agent Framework code paths.
|
||||
|
||||
To override the disable later, call ``enable_instrumentation(force=True)`` or
|
||||
``enable_sensitive_telemetry(force=True)``. This makes the user's intent to opt out
|
||||
win against framework code that would otherwise re-enable instrumentation
|
||||
automatically.
|
||||
|
||||
Note:
|
||||
Disabling does not tear down already-configured OpenTelemetry providers,
|
||||
exporters, or in-flight spans; it gates future captures by Agent Framework
|
||||
instrumentation only. To stop emitting telemetry from third-party
|
||||
instrumentations as well, configure them separately.
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
OBSERVABILITY_SETTINGS._user_disabled = True # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._enable_instrumentation = False # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._enable_sensitive_data = False # type: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
def enable_instrumentation(
|
||||
*,
|
||||
enable_sensitive_data: bool | None = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Enable instrumentation for your application.
|
||||
"""Enable instrumentation for Microsoft Agent Framework.
|
||||
|
||||
Calling this method implies you want to enable observability in your application.
|
||||
|
||||
This method does not configure exporters or providers.
|
||||
It only updates the global variables that trigger the instrumentation code.
|
||||
If you have already set the environment variable ENABLE_INSTRUMENTATION=true,
|
||||
calling this method has no effect, unless you want to enable or disable sensitive data events.
|
||||
Note that instrumentation is enabled by default, so this method is only necessary
|
||||
if you need a programmatic way to enable it (e.g., if you are not sure whether the
|
||||
environment variable ENABLE_INSTRUMENTATION is set to True or False and want to
|
||||
ensure it is enabled).
|
||||
|
||||
Keyword Args:
|
||||
enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides
|
||||
the environment variable ENABLE_SENSITIVE_DATA if set. Default is None.
|
||||
force: When True, clears any sticky disable previously set by
|
||||
``disable_instrumentation()`` before enabling. Without it, calls are
|
||||
no-ops if instrumentation has been explicitly disabled.
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
|
||||
logger.info(
|
||||
"enable_instrumentation() ignored: instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(). Pass force=True to re-enable."
|
||||
)
|
||||
return
|
||||
if force:
|
||||
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
if enable_sensitive_data is not None:
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data
|
||||
@@ -1008,7 +1150,7 @@ def configure_otel_providers(
|
||||
Since you can only setup one provider per signal type (logs, traces, metrics),
|
||||
you can choose to use this method and take the exporter and provider that we created.
|
||||
Alternatively, you can setup the providers yourself, or through another library
|
||||
(e.g., Azure Monitor) and just call `enable_instrumentation()` to enable instrumentation.
|
||||
(e.g., Azure Monitor) and just call `enable_sensitive_telemetry()` to opt-in to sensitive data capture.
|
||||
|
||||
Note:
|
||||
By default, the Agent Framework emits metrics with the prefixes `agent_framework`
|
||||
@@ -1042,7 +1184,6 @@ def configure_otel_providers(
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
|
||||
# Using environment variables (recommended)
|
||||
# Set ENABLE_INSTRUMENTATION=true
|
||||
# Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
configure_otel_providers()
|
||||
|
||||
@@ -1087,18 +1228,25 @@ def configure_otel_providers(
|
||||
.. code-block:: python
|
||||
|
||||
# when azure monitor is installed
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
|
||||
connection_string = "InstrumentationKey=your_instrumentation_key_here;..."
|
||||
configure_azure_monitor(connection_string=connection_string)
|
||||
enable_instrumentation()
|
||||
# Optional: opt into capturing sensitive data
|
||||
enable_sensitive_telemetry()
|
||||
|
||||
References:
|
||||
- https://opentelemetry.io/docs/languages/sdk-configuration/general/
|
||||
- https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
if OBSERVABILITY_SETTINGS._user_disabled: # type: ignore[reportPrivateUsage]
|
||||
logger.info(
|
||||
"configure_otel_providers(): instrumentation was explicitly disabled via "
|
||||
"disable_instrumentation(); providers and exporters will still be configured but "
|
||||
"Agent Framework will emit no telemetry until enable_instrumentation(force=True) is called."
|
||||
)
|
||||
if env_file_path:
|
||||
# Build kwargs, excluding None values
|
||||
settings_kwargs: dict[str, Any] = {
|
||||
@@ -1280,7 +1428,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
if stream:
|
||||
span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL)
|
||||
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1344,6 +1492,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
|
||||
and isinstance(response, ChatResponse)
|
||||
and response.messages
|
||||
and span.is_recording()
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
@@ -1374,7 +1523,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
|
||||
async def _get_response() -> ChatResponse:
|
||||
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.REQUEST_MODEL) as span:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1408,7 +1557,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
duration=duration,
|
||||
)
|
||||
_mark_inner_response_telemetry_captured(response)
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
|
||||
finish_reason = cast(
|
||||
"FinishReason | None",
|
||||
response.finish_reason if response.finish_reason in FINISH_REASON_MAP else None,
|
||||
@@ -1552,7 +1701,7 @@ class AgentTelemetryLayer:
|
||||
if stream:
|
||||
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)
|
||||
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1613,6 +1762,7 @@ class AgentTelemetryLayer:
|
||||
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
|
||||
and isinstance(response, AgentResponse)
|
||||
and response.messages
|
||||
and span.is_recording()
|
||||
):
|
||||
_capture_messages(
|
||||
span=span,
|
||||
@@ -1645,7 +1795,7 @@ class AgentTelemetryLayer:
|
||||
async def _run() -> AgentResponse[Any]:
|
||||
try:
|
||||
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
@@ -1669,7 +1819,7 @@ class AgentTelemetryLayer:
|
||||
)
|
||||
_apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields)
|
||||
_capture_response(span=span, attributes=response_attributes, duration=duration)
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
|
||||
_capture_messages(
|
||||
span=span,
|
||||
provider_name=provider_name,
|
||||
|
||||
@@ -4031,14 +4031,102 @@ async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompt
|
||||
assert tool._prompts_loaded is True
|
||||
|
||||
|
||||
async def test_connect_skips_tools_and_prompts_when_server_does_not_advertise_capabilities() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
|
||||
tool.is_connected = True
|
||||
tool.session = Mock()
|
||||
tool.session._request_id = 0
|
||||
tool.session.initialize = AsyncMock(
|
||||
return_value=types.InitializeResult(
|
||||
protocolVersion=types.LATEST_PROTOCOL_VERSION,
|
||||
capabilities=types.ServerCapabilities(),
|
||||
serverInfo=types.Implementation(name="test", version="1.0"),
|
||||
)
|
||||
)
|
||||
tool.session.list_tools = AsyncMock()
|
||||
tool.session.list_prompts = AsyncMock()
|
||||
tool.session.set_logging_level = AsyncMock()
|
||||
|
||||
with patch.object(logger, "level", logging.INFO):
|
||||
await tool._connect_on_owner()
|
||||
|
||||
tool.session.initialize.assert_awaited_once()
|
||||
tool.session.list_tools.assert_not_called()
|
||||
tool.session.list_prompts.assert_not_called()
|
||||
tool.session.set_logging_level.assert_not_called()
|
||||
assert tool.is_connected is True
|
||||
assert tool._supports_tools is False
|
||||
assert tool._supports_prompts is False
|
||||
assert tool._supports_logging is False
|
||||
assert tool._tools_loaded is True
|
||||
assert tool._prompts_loaded is True
|
||||
|
||||
|
||||
async def test_connect_treats_missing_capabilities_as_unsupported() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
|
||||
tool.is_connected = True
|
||||
tool.session = Mock()
|
||||
tool.session._request_id = 0
|
||||
tool.session.initialize = AsyncMock(return_value=Mock(capabilities=None))
|
||||
tool.session.list_tools = AsyncMock()
|
||||
tool.session.list_prompts = AsyncMock()
|
||||
|
||||
with patch.object(logger, "level", logging.NOTSET):
|
||||
await tool._connect_on_owner()
|
||||
|
||||
tool.session.list_tools.assert_not_called()
|
||||
tool.session.list_prompts.assert_not_called()
|
||||
assert tool._supports_tools is False
|
||||
assert tool._supports_prompts is False
|
||||
assert tool._supports_logging is False
|
||||
|
||||
|
||||
async def test_connect_sets_logging_level_when_server_advertises_logging() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=False, load_prompts=False)
|
||||
tool.is_connected = True
|
||||
tool.session = Mock()
|
||||
tool.session._request_id = 0
|
||||
tool.session.initialize = AsyncMock(
|
||||
return_value=types.InitializeResult(
|
||||
protocolVersion=types.LATEST_PROTOCOL_VERSION,
|
||||
capabilities=types.ServerCapabilities(logging=types.LoggingCapability()),
|
||||
serverInfo=types.Implementation(name="test", version="1.0"),
|
||||
)
|
||||
)
|
||||
tool.session.set_logging_level = AsyncMock()
|
||||
|
||||
with patch.object(logger, "level", logging.INFO):
|
||||
await tool._connect_on_owner()
|
||||
|
||||
tool.session.set_logging_level.assert_awaited_once_with("info")
|
||||
assert tool._supports_logging is True
|
||||
|
||||
|
||||
async def test_ensure_connected_skips_future_pings_when_ping_is_not_available() -> None:
|
||||
tool = MCPTool(name="test_tool")
|
||||
tool.session = Mock(
|
||||
send_ping=AsyncMock(
|
||||
side_effect=McpError(types.ErrorData(code=-32601, message="Method 'ping' is not available."))
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock()) as mock_reconnect:
|
||||
await tool._ensure_connected()
|
||||
await tool._ensure_connected()
|
||||
|
||||
tool.session.send_ping.assert_awaited_once()
|
||||
mock_reconnect.assert_not_awaited()
|
||||
assert tool._ping_available is False
|
||||
|
||||
|
||||
async def test_ensure_connected_reconnects_on_failed_ping() -> None:
|
||||
tool = MCPTool(name="test_tool")
|
||||
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
|
||||
|
||||
with patch.object(tool, "connect", AsyncMock()) as mock_connect:
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock()) as mock_reconnect:
|
||||
await tool._ensure_connected()
|
||||
|
||||
mock_connect.assert_awaited_once_with(reset=True)
|
||||
mock_reconnect.assert_awaited_once_with()
|
||||
|
||||
|
||||
async def test_ensure_connected_wraps_reconnect_failure() -> None:
|
||||
@@ -4046,12 +4134,70 @@ async def test_ensure_connected_wraps_reconnect_failure() -> None:
|
||||
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
|
||||
|
||||
with (
|
||||
patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("still closed"))),
|
||||
patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=RuntimeError("still closed"))),
|
||||
pytest.raises(ToolExecutionException, match="Failed to establish MCP connection"),
|
||||
):
|
||||
await tool._ensure_connected()
|
||||
|
||||
|
||||
async def test_load_tools_reconnects_on_closed_resource_when_ping_is_unavailable() -> None:
|
||||
from anyio import ClosedResourceError
|
||||
|
||||
tool = MCPTool(name="test_tool", load_tools=True)
|
||||
tool._ping_available = False
|
||||
|
||||
first_session = Mock()
|
||||
first_session.list_tools = AsyncMock(side_effect=ClosedResourceError())
|
||||
tool.session = first_session
|
||||
|
||||
page = Mock()
|
||||
page.tools = []
|
||||
page.nextCursor = None
|
||||
|
||||
second_session = Mock()
|
||||
second_session.list_tools = AsyncMock(return_value=page)
|
||||
|
||||
async def reconnect() -> None:
|
||||
tool.session = second_session
|
||||
tool._supports_tools = True
|
||||
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=reconnect)) as mock_reconnect:
|
||||
await tool.load_tools()
|
||||
|
||||
first_session.list_tools.assert_awaited_once()
|
||||
mock_reconnect.assert_awaited_once_with()
|
||||
second_session.list_tools.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_load_prompts_reconnects_on_closed_resource_when_ping_is_unavailable() -> None:
|
||||
from anyio import ClosedResourceError
|
||||
|
||||
tool = MCPTool(name="test_tool", load_prompts=True)
|
||||
tool._ping_available = False
|
||||
|
||||
first_session = Mock()
|
||||
first_session.list_prompts = AsyncMock(side_effect=ClosedResourceError())
|
||||
tool.session = first_session
|
||||
|
||||
page = Mock()
|
||||
page.prompts = []
|
||||
page.nextCursor = None
|
||||
|
||||
second_session = Mock()
|
||||
second_session.list_prompts = AsyncMock(return_value=page)
|
||||
|
||||
async def reconnect() -> None:
|
||||
tool.session = second_session
|
||||
tool._supports_prompts = True
|
||||
|
||||
with patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=reconnect)) as mock_reconnect:
|
||||
await tool.load_prompts()
|
||||
|
||||
first_session.list_prompts.assert_awaited_once()
|
||||
mock_reconnect.assert_awaited_once_with()
|
||||
second_session.list_prompts.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_filters_framework_kwargs():
|
||||
"""Test that call_tool filters out framework-specific kwargs before calling MCP session.
|
||||
|
||||
|
||||
@@ -1015,11 +1015,25 @@ def test_observability_settings_is_setup_initial(monkeypatch):
|
||||
assert settings.is_setup is False
|
||||
|
||||
|
||||
# region Test enable_instrumentation function
|
||||
def test_enable_sensitive_telemetry_function(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry function enables instrumentation."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_function(monkeypatch):
|
||||
"""Test enable_instrumentation function enables instrumentation."""
|
||||
"""Test enable_instrumentation function enables instrumentation when disabled via env."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
@@ -1032,10 +1046,12 @@ def test_enable_instrumentation_function(monkeypatch):
|
||||
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
# Sensitive data should remain False when not explicitly enabled
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_with_sensitive_data(monkeypatch):
|
||||
"""Test enable_instrumentation function with sensitive_data parameter."""
|
||||
"""Test enable_instrumentation function with explicit sensitive_data parameter."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
@@ -1049,111 +1065,6 @@ def test_enable_instrumentation_with_sensitive_data(monkeypatch):
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test enable_instrumentation re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
|
||||
|
||||
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
|
||||
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
|
||||
|
||||
|
||||
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit parameters to configure_otel_providers override env vars."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_sensitive_data=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit enable_sensitive_data parameter to enable_instrumentation overrides env var."""
|
||||
import importlib
|
||||
@@ -1269,6 +1180,161 @@ def test_enable_instrumentation_preserves_console_exporters_after_env_removed(mo
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
|
||||
|
||||
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
|
||||
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
|
||||
|
||||
|
||||
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit parameters to configure_otel_providers override env vars."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_sensitive_data=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_does_not_touch_console_exporters(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry does not modify enable_console_exporters (it is an exporter concern)."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
observability.enable_sensitive_telemetry()
|
||||
# enable_console_exporters is not managed by enable_sensitive_telemetry;
|
||||
# it is only read by configure_otel_providers.
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_does_not_clobber_console_exporters(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry does not reset enable_console_exporters set by prior configure call."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Set console exporters via configure_otel_providers
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Calling enable_sensitive_telemetry should not clobber the value
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_preserves_console_exporters_after_env_removed(monkeypatch):
|
||||
"""Test enable_sensitive_telemetry preserves enable_console_exporters when env var is removed after reload."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Remove the env var after reload
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
|
||||
# enable_sensitive_telemetry should not reset the value
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_console_exporters(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_CONSOLE_EXPORTERS from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
@@ -1321,6 +1387,189 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
|
||||
# region Test default-on instrumentation
|
||||
|
||||
|
||||
def test_observability_settings_defaults_instrumentation_true(monkeypatch):
|
||||
"""ENABLE_INSTRUMENTATION unset → ObservabilitySettings defaults to True."""
|
||||
from agent_framework.observability import ObservabilitySettings
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
settings = ObservabilitySettings()
|
||||
assert settings.enable_instrumentation is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
|
||||
"""No-arg enable_instrumentation() re-reads ENABLE_SENSITIVE_DATA from env at call time.
|
||||
|
||||
Covers the fallback branch where the env var is set AFTER import (e.g. via load_dotenv()).
|
||||
"""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Simulate load_dotenv() setting the env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
observability.enable_instrumentation()
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
# region Test disable_instrumentation sticky behavior
|
||||
|
||||
|
||||
def test_disable_instrumentation_flips_settings_off(monkeypatch):
|
||||
"""disable_instrumentation() immediately turns instrumentation and sensitive data off."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is True
|
||||
|
||||
observability.disable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.ENABLED is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_is_sticky_against_enable_instrumentation(monkeypatch):
|
||||
"""Sticky disable: enable_instrumentation() without force is a no-op after disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation(enable_sensitive_data=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_is_sticky_against_enable_sensitive_telemetry(monkeypatch):
|
||||
"""Sticky disable: enable_sensitive_telemetry() without force is a no-op after disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_sensitive_telemetry()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_is_sticky_against_configure_otel_providers(monkeypatch):
|
||||
"""Sticky disable: configure_otel_providers() does not flip instrumentation back on."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_sensitive_data=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_intercepts_direct_attribute_writes(monkeypatch):
|
||||
"""Sticky disable: direct OBSERVABILITY_SETTINGS.enable_instrumentation = True is intercepted."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
observability.OBSERVABILITY_SETTINGS.enable_sensitive_data = True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_force_clears_disable(monkeypatch):
|
||||
"""enable_instrumentation(force=True) clears the sticky disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation(force=True, enable_sensitive_data=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_enable_sensitive_telemetry_force_clears_disable(monkeypatch):
|
||||
"""enable_sensitive_telemetry(force=True) clears the sticky disable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_sensitive_telemetry(force=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_disable_instrumentation_persists_after_force_until_redisabled(monkeypatch):
|
||||
"""After force-enable then disable again, the sticky disable is re-armed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation(force=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
|
||||
observability.disable_instrumentation()
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
|
||||
|
||||
|
||||
def test_disable_instrumentation_in_all(monkeypatch):
|
||||
"""disable_instrumentation must be re-exported from the module's __all__."""
|
||||
import agent_framework.observability as observability
|
||||
|
||||
assert "disable_instrumentation" in observability.__all__
|
||||
assert callable(observability.disable_instrumentation)
|
||||
|
||||
|
||||
# region Test _to_otel_part content types
|
||||
|
||||
|
||||
@@ -3797,3 +4046,135 @@ async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvar
|
||||
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
assert len(agent_spans) == 1
|
||||
assert agent_spans[0].status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
# region Test heavy operations skipped when span is not recording
|
||||
#
|
||||
# When ``ENABLE_INSTRUMENTATION`` is on (the default) but no OpenTelemetry
|
||||
# tracer provider has been configured, the global provider is the
|
||||
# ``ProxyTracerProvider`` which returns non-recording spans. The telemetry
|
||||
# layers gate sensitive-data serialization (``_capture_messages``) on
|
||||
# ``span.is_recording()`` so that we don't pay the JSON-serialization cost
|
||||
# when the span is going to be dropped anyway. The tests below verify that
|
||||
# behavior by patching ``get_tracer`` to return a ``NoOpTracer``.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Heavy message serialization is skipped when no provider is configured (non-streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
client = mock_chat_client()
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
response = await client.get_response(messages=messages, options={"model": "Test"})
|
||||
|
||||
assert response is not None
|
||||
# Sensitive-data serialization must be skipped because span.is_recording() is False.
|
||||
assert mock_capture_messages.call_count == 0
|
||||
# _capture_response still runs so that metric histograms continue to record.
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_streaming_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Heavy message serialization is skipped when no provider is configured (streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
client = mock_chat_client()
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
updates: list[ChatResponseUpdate] = []
|
||||
stream = client.get_response(messages=messages, stream=True, options={"model": "Test"})
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await stream.get_final_response()
|
||||
|
||||
assert len(updates) == 2
|
||||
assert mock_capture_messages.call_count == 0
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Agent heavy serialization is skipped when no provider is configured (non-streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
agent = mock_chat_agent()
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
response = await agent.run("Test message")
|
||||
|
||||
assert response is not None
|
||||
assert mock_capture_messages.call_count == 0
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_streaming_capture_messages_skipped_when_span_not_recording(
|
||||
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Agent heavy serialization is skipped when no provider is configured (streaming)."""
|
||||
from opentelemetry.trace import NoOpTracer
|
||||
|
||||
agent = mock_chat_agent()
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
updates: list[Any] = []
|
||||
stream = agent.run("Test message", stream=True)
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await stream.get_final_response()
|
||||
|
||||
assert len(updates) == 2
|
||||
assert mock_capture_messages.call_count == 0
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_capture_messages_called_when_span_recording(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Sanity check: with a real recording provider, sensitive-data capture still runs."""
|
||||
client = mock_chat_client()
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
span_exporter.clear()
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
|
||||
patch("agent_framework.observability._capture_response") as mock_capture_response,
|
||||
):
|
||||
response = await client.get_response(messages=messages, options={"model": "Test"})
|
||||
|
||||
assert response is not None
|
||||
# Two _capture_messages calls: one for input, one for output messages.
|
||||
assert mock_capture_messages.call_count == 2
|
||||
assert mock_capture_response.call_count == 1
|
||||
|
||||
@@ -793,8 +793,22 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
Raises:
|
||||
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
|
||||
"""
|
||||
from agent_framework.observability import (
|
||||
OBSERVABILITY_SETTINGS,
|
||||
create_metric_views,
|
||||
create_resource,
|
||||
enable_instrumentation,
|
||||
)
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
if OBSERVABILITY_SETTINGS.is_user_disabled:
|
||||
logger.info(
|
||||
"FoundryAgent.configure_azure_monitor(): Skipping setup because instrumentation was "
|
||||
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
|
||||
"to re-enable, then re-invoke configure_azure_monitor()."
|
||||
)
|
||||
return
|
||||
|
||||
client = self.client
|
||||
if not isinstance(client, RawFoundryAgentChatClient):
|
||||
raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.")
|
||||
@@ -817,8 +831,6 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
"Install it with: pip install azure-monitor-opentelemetry"
|
||||
) from exc
|
||||
|
||||
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
|
||||
|
||||
if "resource" not in kwargs:
|
||||
kwargs["resource"] = create_resource()
|
||||
|
||||
|
||||
@@ -271,8 +271,22 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
Raises:
|
||||
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
|
||||
"""
|
||||
from agent_framework.observability import (
|
||||
OBSERVABILITY_SETTINGS,
|
||||
create_metric_views,
|
||||
create_resource,
|
||||
enable_instrumentation,
|
||||
)
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
if OBSERVABILITY_SETTINGS.is_user_disabled:
|
||||
logger.info(
|
||||
"FoundryChatClient.configure_azure_monitor(): Skipping setup because instrumentation was "
|
||||
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
|
||||
"to re-enable, then re-invoke configure_azure_monitor()."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
|
||||
except ResourceNotFoundError:
|
||||
@@ -291,8 +305,6 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
"Install it with: pip install azure-monitor-opentelemetry"
|
||||
) from exc
|
||||
|
||||
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
|
||||
|
||||
if "resource" not in kwargs:
|
||||
kwargs["resource"] = create_resource()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import threading
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from typing import Protocol, cast
|
||||
|
||||
from agent_framework import (
|
||||
@@ -25,12 +26,14 @@ from agent_framework import (
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
)
|
||||
from agent_framework.exceptions import AgentFrameworkException
|
||||
from azure.ai.agentserver.responses import (
|
||||
ResponseContext,
|
||||
ResponseEventStream,
|
||||
ResponseProviderProtocol,
|
||||
ResponsesServerOptions,
|
||||
)
|
||||
from azure.ai.agentserver.responses._id_generator import IdGenerator
|
||||
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
ApplyPatchToolCallItemParam,
|
||||
@@ -108,11 +111,13 @@ from azure.ai.agentserver.responses.streaming._builders import (
|
||||
ReasoningSummaryPartBuilder,
|
||||
TextContentBuilder,
|
||||
)
|
||||
from mcp import McpError
|
||||
from typing_extensions import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Approval Storage
|
||||
class ApprovalStorage(Protocol):
|
||||
"""Storage for saving function approval requests."""
|
||||
|
||||
@@ -247,6 +252,39 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
|
||||
return FileCheckpointStorage(storage_path)
|
||||
|
||||
|
||||
# endregion Approval Storage
|
||||
|
||||
# Foundry Toolbox Auth integration
|
||||
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
|
||||
CONSENT_ERROR_CODE = -32007
|
||||
|
||||
|
||||
def consent_url_from_error(exc: BaseException) -> str | None:
|
||||
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
|
||||
|
||||
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
|
||||
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException``
|
||||
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a
|
||||
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
|
||||
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
|
||||
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
|
||||
|
||||
Args:
|
||||
exc: The exception to inspect.
|
||||
|
||||
Returns:
|
||||
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
|
||||
"""
|
||||
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
|
||||
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
|
||||
return inner_exception.error.message
|
||||
return None
|
||||
|
||||
|
||||
# endregion Foundry Toolbox Auth integration
|
||||
|
||||
|
||||
# region ResponsesHostServer
|
||||
class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
"""A responses server host for an agent."""
|
||||
|
||||
@@ -315,8 +353,43 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
if self.config.is_hosted
|
||||
else InMemoryFunctionApprovalStorage()
|
||||
)
|
||||
# Lazy agent lifecycle: the agent (and any MCP tools it owns) is entered on
|
||||
# the first request rather than at server startup, so that authentication
|
||||
# failures during MCP connect can be surfaced to the client as an
|
||||
# `oauth_consent_request` stream event instead of crashing the server.
|
||||
self._agent_stack: AsyncExitStack | None = None
|
||||
self._agent_init_lock = asyncio.Lock()
|
||||
self.shutdown_handler(self._cleanup_agent) # pyright: ignore[reportUnknownMemberType]
|
||||
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
async def _ensure_agent_ready(self) -> None:
|
||||
"""Lazily enter the agent's async context exactly once.
|
||||
|
||||
On failure the partial exit stack is closed and ``_agent_stack`` is left
|
||||
as ``None`` so a subsequent request (e.g. after the user completes OAuth
|
||||
consent) can retry the connection.
|
||||
"""
|
||||
if self._agent_stack is not None:
|
||||
return
|
||||
async with self._agent_init_lock:
|
||||
if self._agent_stack is not None:
|
||||
return
|
||||
stack = AsyncExitStack()
|
||||
try:
|
||||
if isinstance(self._agent, AbstractAsyncContextManager):
|
||||
await stack.enter_async_context(self._agent)
|
||||
except BaseException:
|
||||
await stack.aclose()
|
||||
raise
|
||||
self._agent_stack = stack
|
||||
|
||||
async def _cleanup_agent(self) -> None:
|
||||
"""Close the agent's async context. Registered as the server shutdown handler."""
|
||||
stack = self._agent_stack
|
||||
if stack is not None:
|
||||
self._agent_stack = None
|
||||
await stack.aclose()
|
||||
|
||||
async def _handle_response(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
@@ -359,45 +432,76 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
else:
|
||||
run_kwargs["options"] = chat_options
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
|
||||
# Lazy-enter the agent (and any MCP tools it owns). The MCP client wraps gateway
|
||||
# consent failures (and other connection-time errors) in AgentFrameworkException; if
|
||||
# one of those is a consent error we surface the consent link to the client through
|
||||
# the already-opened response stream instead of crashing the request. Other exception
|
||||
# types propagate normally so the host can handle / log them.
|
||||
try:
|
||||
await self._ensure_agent_ready()
|
||||
except AgentFrameworkException as ex:
|
||||
consent_url = consent_url_from_error(ex)
|
||||
if consent_url is None:
|
||||
raise
|
||||
logger.warning("OAuth consent required for Foundry MCP gateway.")
|
||||
oauth_item = OAuthConsentRequestOutputItem(
|
||||
id=IdGenerator.new_id("oacr"),
|
||||
consent_link=consent_url,
|
||||
server_label="Foundry Toolbox",
|
||||
)
|
||||
builder = response_event_stream.add_output_item(oauth_item.id)
|
||||
yield builder.emit_added(oauth_item)
|
||||
yield builder.emit_done(oauth_item)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
# Track the current active output item builder for streaming;
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
tracker: _OutputItemTracker | None = _OutputItemTracker(response_event_stream) if is_streaming_request else None
|
||||
|
||||
# Run the agent in streaming mode
|
||||
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
try:
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
yield response_event_stream.emit_completed()
|
||||
else:
|
||||
if tracker is None: # pragma: no cover - defensive, set above
|
||||
raise RuntimeError("Streaming tracker was not initialized.")
|
||||
# Run the agent in streaming mode
|
||||
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
# Close any remaining active builder
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(
|
||||
response_event_stream,
|
||||
content,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
# Close any remaining active builder
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
|
||||
yield response_event_stream.emit_completed()
|
||||
yield response_event_stream.emit_completed()
|
||||
except Exception:
|
||||
# Drain any in-progress streaming builder before emitting consent
|
||||
# so the resulting stream stays well-formed.
|
||||
if tracker is not None:
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
yield response_event_stream.emit_completed()
|
||||
raise
|
||||
|
||||
async def _handle_inner_workflow(
|
||||
self,
|
||||
@@ -429,6 +533,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
if not isinstance(self._agent, WorkflowAgent):
|
||||
raise RuntimeError("Agent is not a workflow agent.")
|
||||
|
||||
# Workflow agents are not async context managers in any built-in path,
|
||||
# but call _ensure_agent_ready for symmetry with the regular path so
|
||||
# any future async resources owned by the workflow are entered here.
|
||||
await self._ensure_agent_ready()
|
||||
|
||||
# Determine the latest checkpoint (if any) so we can resume the
|
||||
# workflow's prior state for this turn. The directory is keyed by
|
||||
# the inbound context id (conversation_id when set, otherwise
|
||||
@@ -551,6 +660,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
await checkpoint_storage.delete(checkpoint.checkpoint_id)
|
||||
|
||||
|
||||
# endregion ResponsesHostServer
|
||||
|
||||
# region Active Builder State
|
||||
|
||||
|
||||
|
||||
@@ -27,14 +27,18 @@ from agent_framework import (
|
||||
ResponseStream,
|
||||
)
|
||||
from azure.ai.agentserver.responses import InMemoryResponseProvider
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
from typing_extensions import Any
|
||||
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_foundry_hosting._responses import (
|
||||
CONSENT_ERROR_CODE,
|
||||
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
_output_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
consent_url_from_error,
|
||||
)
|
||||
|
||||
|
||||
@@ -2888,6 +2892,187 @@ class TestCheckpointContextPathValidation:
|
||||
f"before={before} after={after}"
|
||||
)
|
||||
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
|
||||
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
|
||||
|
||||
|
||||
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
|
||||
"""Build an exception wrapping a Foundry MCP gateway consent error.
|
||||
|
||||
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
|
||||
which catches connection-time ``McpError``s and re-raises them as a
|
||||
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
|
||||
original error attached via ``inner_exception``. ``consent_url_from_error``
|
||||
then finds the wrapped ``McpError`` in ``exc.args``.
|
||||
"""
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
|
||||
return ToolExecutionException("MCP consent required", inner_exception=inner)
|
||||
|
||||
|
||||
class TestConsentUrlFromError:
|
||||
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
|
||||
exc = _make_consent_error("https://example.com/consent")
|
||||
assert consent_url_from_error(exc) == "https://example.com/consent"
|
||||
|
||||
def test_returns_none_when_no_mcp_error_in_args(self) -> None:
|
||||
assert consent_url_from_error(Exception("boom")) is None
|
||||
|
||||
def test_returns_none_when_mcp_error_has_different_code(self) -> None:
|
||||
inner = McpError(ErrorData(code=-32000, message="some other error"))
|
||||
exc = Exception("wrapped", inner)
|
||||
assert consent_url_from_error(exc) is None
|
||||
|
||||
def test_returns_none_for_bare_mcp_error_without_wrapping(self) -> None:
|
||||
# `args` of a bare McpError holds the message string, not an McpError
|
||||
# instance, so it does not match the wrapping pattern produced by the
|
||||
# MCP client when it bubbles consent errors up.
|
||||
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
|
||||
assert consent_url_from_error(bare) is None
|
||||
|
||||
|
||||
class TestAgentLifecycle:
|
||||
async def test_agent_entered_lazily_on_first_request(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
# Construction must not enter the agent.
|
||||
assert agent.__aenter__.await_count == 0
|
||||
|
||||
await _post(server, input_text="hello", stream=False)
|
||||
assert agent.__aenter__.await_count == 1
|
||||
|
||||
async def test_agent_entered_only_once_across_requests(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
|
||||
await _post(server, input_text="first", stream=False)
|
||||
await _post(server, input_text="second", stream=False)
|
||||
await _post(server, input_text="third", stream=False)
|
||||
assert agent.__aenter__.await_count == 1
|
||||
|
||||
async def test_cleanup_exits_agent_and_allows_reentry(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
|
||||
await _post(server, input_text="hello", stream=False)
|
||||
assert agent.__aenter__.await_count == 1
|
||||
assert agent.__aexit__.await_count == 0
|
||||
|
||||
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
|
||||
assert agent.__aexit__.await_count == 1
|
||||
|
||||
# Cleanup is idempotent.
|
||||
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
|
||||
assert agent.__aexit__.await_count == 1
|
||||
|
||||
# After cleanup, a follow-up request re-enters the agent.
|
||||
await _post(server, input_text="again", stream=False)
|
||||
assert agent.__aenter__.await_count == 2
|
||||
|
||||
async def test_failed_entry_does_not_cache_stack(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = [_make_consent_error(), None]
|
||||
server = _make_server(agent)
|
||||
|
||||
await _post(server, input_text="first", stream=False)
|
||||
# Failed entry must leave the stack empty so the next request retries.
|
||||
await _post(server, input_text="second", stream=False)
|
||||
assert agent.__aenter__.await_count == 2
|
||||
|
||||
|
||||
class TestOAuthConsentSurfacing:
|
||||
async def test_non_streaming_consent_error_emits_oauth_output_item(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post(server, input_text="hello", stream=False)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
oauth_items = [it for it in body["output"] if it["type"] == "oauth_consent_request"]
|
||||
assert len(oauth_items) == 1
|
||||
assert oauth_items[0]["consent_link"] == "https://consent.example.com/auth"
|
||||
assert oauth_items[0]["server_label"] == "Foundry Toolbox"
|
||||
|
||||
# The agent must not be run when entry fails.
|
||||
agent.run.assert_not_called()
|
||||
|
||||
async def test_streaming_consent_error_emits_oauth_output_item(self) -> None:
|
||||
agent = _make_agent(stream_updates=[AgentResponseUpdate(contents=[Content.from_text("hi")], role="assistant")])
|
||||
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post(server, input_text="hello", stream=True)
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[1] == "response.in_progress"
|
||||
assert types[-1] == "response.completed"
|
||||
|
||||
added = [e for e in events if e["event"] == "response.output_item.added"]
|
||||
oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"]
|
||||
assert len(oauth_added) == 1
|
||||
assert oauth_added[0]["data"]["item"]["consent_link"] == "https://consent.example.com/auth"
|
||||
assert oauth_added[0]["data"]["item"]["server_label"] == "Foundry Toolbox"
|
||||
|
||||
done = [e for e in events if e["event"] == "response.output_item.done"]
|
||||
assert any(e["data"]["item"]["type"] == "oauth_consent_request" for e in done)
|
||||
|
||||
agent.run.assert_not_called()
|
||||
|
||||
async def test_non_consent_error_during_entry_propagates(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = RuntimeError("boom")
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post(server, input_text="hello", stream=False)
|
||||
# Non-consent errors are not swallowed: the response is marked failed
|
||||
# and no `oauth_consent_request` item is emitted.
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "failed"
|
||||
assert not any(it["type"] == "oauth_consent_request" for it in body.get("output", []))
|
||||
agent.run.assert_not_called()
|
||||
|
||||
async def test_retry_after_consent_succeeds(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hello!")])])
|
||||
)
|
||||
agent.__aenter__.side_effect = [_make_consent_error("https://consent.example.com/auth"), None]
|
||||
server = _make_server(agent)
|
||||
|
||||
# First request surfaces consent; agent.run is not called.
|
||||
resp1 = await _post(server, input_text="first", stream=False)
|
||||
assert resp1.status_code == 200
|
||||
body1 = resp1.json()
|
||||
oauth = [it for it in body1["output"] if it["type"] == "oauth_consent_request"]
|
||||
assert len(oauth) == 1
|
||||
agent.run.assert_not_called()
|
||||
|
||||
# After the user authenticates, the next request enters successfully.
|
||||
resp2 = await _post(server, input_text="second", stream=False)
|
||||
assert resp2.status_code == 200
|
||||
body2 = resp2.json()
|
||||
assert body2["status"] == "completed"
|
||||
assert any(it["type"] == "message" for it in body2["output"])
|
||||
assert agent.__aenter__.await_count == 2
|
||||
agent.run.assert_awaited_once()
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Monty Package (agent-framework-monty)
|
||||
|
||||
Monty-backed CodeAct integrations for the Microsoft Agent Framework.
|
||||
|
||||
> [!NOTE]
|
||||
> **Alpha package.** Not part of `agent-framework[all]` yet. Install explicitly
|
||||
> with `pip install agent-framework-monty --pre`.
|
||||
|
||||
## Core Classes
|
||||
|
||||
- **`MontyCodeActProvider`** — `ContextProvider` that injects a run-scoped
|
||||
`execute_code` tool plus dynamic CodeAct instructions. Mirrors the
|
||||
`HyperlightCodeActProvider` API for the parts that apply to a non-sandboxed
|
||||
Python interpreter.
|
||||
- **`MontyExecuteCodeTool`** — `FunctionTool` that wraps the Monty interpreter.
|
||||
Use directly for mixed-tool agents or manual static wiring. Mirrors
|
||||
`HyperlightExecuteCodeTool`.
|
||||
|
||||
## Public API
|
||||
|
||||
```python
|
||||
from agent_framework_monty import (
|
||||
FileMount,
|
||||
FileMountInput,
|
||||
MontyCodeActProvider,
|
||||
MontyExecuteCodeTool,
|
||||
MountMode,
|
||||
)
|
||||
```
|
||||
|
||||
`MontyCodeActProvider` and `MontyExecuteCodeTool` both accept:
|
||||
- `tools` — host tool callables / `FunctionTool`s
|
||||
- `approval_mode` — `"never_require"` (default) or `"always_require"`
|
||||
- `workspace_root` — host directory auto-mounted at `/input`
|
||||
(mirrors `HyperlightCodeActProvider.workspace_root`)
|
||||
- `file_mounts` — sequence of `FileMountInput` (str shorthand,
|
||||
`(host_path, mount_path)` tuple, or `FileMount`)
|
||||
- `resource_limits` — Monty `ResourceLimits` TypedDict
|
||||
|
||||
Tool-management methods on both classes: `add_tools`, `get_tools`,
|
||||
`remove_tool`, `clear_tools`. Mount-management methods: `add_file_mounts`,
|
||||
`get_file_mounts`, `remove_file_mount`, `clear_file_mounts`.
|
||||
|
||||
`MontyExecuteCodeTool` additionally exposes:
|
||||
- `build_instructions(*, tools_visible_to_model: bool) -> str`
|
||||
- `create_run_tool() -> MontyExecuteCodeTool`
|
||||
- `build_serializable_state() -> dict[str, Any]`
|
||||
- `workspace_root`, `resource_limits` properties
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`_types.py`** — `FileMount`, `FileMountInput`, `MountMode` (public).
|
||||
- **`_provider.py`** — `MontyCodeActProvider` (thin wrapper around the tool).
|
||||
- **`_execute_code_tool.py`** — `MontyExecuteCodeTool` plus tool / mount
|
||||
normalization, approval helpers, dynamic `description`/`instructions`
|
||||
builders, and the post-execution file-capture flow that surfaces files
|
||||
written to `read-write` mounts as `Content.from_data` items.
|
||||
- **`_monty_bridge.py`** — `InlineCodeBridge` and `generate_type_stubs`,
|
||||
adapted from the reference Monty CodeAct repo. Pauses on `FunctionSnapshot`
|
||||
to dispatch host calls, then resumes; supports direct typed tool calls,
|
||||
the `call_tool` fallback, `asyncio.gather` fan-out, and forwards
|
||||
``mount`` / ``limits`` to `Monty(...).start(...)`.
|
||||
- **`_instructions.py`** — dynamic instruction / tool-description builders
|
||||
(include filesystem capability summaries when mounts are configured).
|
||||
|
||||
## Not implemented (yet)
|
||||
|
||||
| Capability | Monty primitive | Status |
|
||||
|------------|-----------------|--------|
|
||||
| Custom virtual filesystem | `OSAccess` subclass passed to `Monty(...).start(os=...)` | Not exposed. Strictly more general than file mounts; useful when you want a fully synthetic FS. |
|
||||
| Outbound URL allow-list | No Monty primitive — expose `fetch_url` as a host tool with the allow-list check in your tool function. | Not exposed in this package; users add it as a regular tool. |
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- **Durable execution** — the reference Monty CodeAct repo also offers a
|
||||
Durable-Functions-backed mode (`DurableCodeBridge`, `register_durable_codeact`,
|
||||
`wait_for_external_event`, per-tool approval via external events). That is
|
||||
intentionally not in this package yet.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,179 @@
|
||||
# agent-framework-monty
|
||||
|
||||
Monty-backed CodeAct integrations for Microsoft Agent Framework.
|
||||
|
||||
> [!WARNING]
|
||||
> This package is in **alpha**. APIs may change without notice. It is not part of
|
||||
> `agent-framework[all]` yet; install it explicitly with `--pre`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install agent-framework-monty --pre
|
||||
```
|
||||
|
||||
The package depends on [`pydantic-monty`](https://github.com/pydantic/monty), a
|
||||
Rust-based Python interpreter, so it runs on Linux, macOS, and Windows wherever
|
||||
Monty wheels are published — no hypervisor or WASM backend required.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Context provider (recommended)
|
||||
|
||||
Use `MontyCodeActProvider` to automatically inject the `execute_code` tool and
|
||||
CodeAct instructions into every agent run. Tools registered on the provider are
|
||||
available inside the Monty interpreter as **typed async functions** (e.g.
|
||||
`await compute(operation="add", a=1, b=2)`), and as a fallback through
|
||||
`call_tool(...)`.
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework_monty import MontyCodeActProvider
|
||||
|
||||
|
||||
@tool
|
||||
def compute(operation: str, a: float, b: float) -> float:
|
||||
"""Perform a math operation."""
|
||||
ops = {"add": a + b, "subtract": a - b, "multiply": a * b, "divide": a / b}
|
||||
return ops[operation]
|
||||
|
||||
|
||||
codeact = MontyCodeActProvider(
|
||||
tools=[compute],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="CodeActAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
context_providers=[codeact],
|
||||
)
|
||||
|
||||
result = await agent.run("Multiply 6 by 7 using execute_code.")
|
||||
```
|
||||
|
||||
### Standalone tool
|
||||
|
||||
Use `MontyExecuteCodeTool` directly when you want full control over how the
|
||||
tool is added to the agent (e.g. when mixing sandbox tools with direct-only
|
||||
tools on the same agent).
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework_monty import MontyExecuteCodeTool
|
||||
|
||||
|
||||
@tool
|
||||
def send_email(to: str, subject: str, body: str) -> str:
|
||||
"""Send an email (direct-only, not available inside the sandbox)."""
|
||||
return f"Email sent to {to}"
|
||||
|
||||
|
||||
execute_code = MontyExecuteCodeTool(
|
||||
tools=[compute],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="MixedToolsAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
tools=[send_email, execute_code],
|
||||
)
|
||||
```
|
||||
|
||||
### Manual static wiring
|
||||
|
||||
For fixed configurations where provider lifecycle overhead is unnecessary,
|
||||
build the CodeAct instructions once and pass them to the agent at construction
|
||||
time:
|
||||
|
||||
```python
|
||||
execute_code = MontyExecuteCodeTool(
|
||||
tools=[compute],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="StaticWiringAgent",
|
||||
instructions=f"You are a helpful assistant.\n\n{codeact_instructions}",
|
||||
tools=[execute_code],
|
||||
)
|
||||
```
|
||||
|
||||
### File mounts and resource limits
|
||||
|
||||
Mount host directories into the sandbox and cap execution resources:
|
||||
|
||||
```python
|
||||
from agent_framework_monty import FileMount, MontyCodeActProvider
|
||||
|
||||
codeact = MontyCodeActProvider(
|
||||
tools=[compute],
|
||||
workspace_root="/host/workspace", # auto-mounted at /input (read-write)
|
||||
file_mounts=[
|
||||
"/host/data", # shorthand: same path on both sides
|
||||
("/host/models", "/sandbox/models"), # explicit (host, mount_path)
|
||||
FileMount( # full control
|
||||
host_path="/host/cache",
|
||||
mount_path="/sandbox/cache",
|
||||
mode="overlay", # "read-only" | "read-write" | "overlay"
|
||||
write_bytes_limit=10 * 1024 * 1024,
|
||||
),
|
||||
],
|
||||
resource_limits={ # Monty ResourceLimits TypedDict
|
||||
"max_duration_secs": 5.0,
|
||||
"max_memory": 64 * 1024 * 1024,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- **`workspace_root`** mirrors the Hyperlight default: the directory is mounted
|
||||
at `/input` in `read-write` mode.
|
||||
- **`file_mounts`** accepts a string shorthand, a `(host_path, mount_path)`
|
||||
tuple, or a `FileMount` named tuple (with optional `mode` and
|
||||
`write_bytes_limit`).
|
||||
- Files written by the sandbox to any **`read-write`** mount are scanned
|
||||
after each `execute_code` call and returned as `Content.from_data(...)`
|
||||
attachments (with a `path` annotation in `additional_properties`),
|
||||
mirroring Hyperlight's `/output` flow.
|
||||
- `overlay` mounts buffer writes in memory (nothing leaks to the host and
|
||||
nothing is captured). `read-only` mounts reject writes.
|
||||
- **`resource_limits`** is forwarded straight to Monty's
|
||||
[`ResourceLimits`](https://github.com/pydantic/monty) TypedDict
|
||||
(`max_allocations`, `max_duration_secs`, `max_memory`, `gc_interval`,
|
||||
`max_recursion_depth`).
|
||||
|
||||
## DSL inside `execute_code`
|
||||
|
||||
The model generates Python code that runs inside Monty's Rust-based interpreter.
|
||||
Available primitives:
|
||||
|
||||
| Primitive | Behavior |
|
||||
|-----------|----------|
|
||||
| `await tool_name(**kwargs)` | Direct typed call to a registered host tool. Argument types are checked before execution. |
|
||||
| `await call_tool("name", **kwargs)` | Generic fallback that dispatches by tool name. Not type-checked. |
|
||||
| `asyncio.gather(...)` | Fans out concurrent tool calls. |
|
||||
| `print(...)` | Captured and surfaced as text in the tool result. |
|
||||
|
||||
## Notes
|
||||
|
||||
- `MontyCodeActProvider` and `MontyExecuteCodeTool` mirror the API surface of
|
||||
the `agent-framework-hyperlight` counterparts where the underlying runtime
|
||||
supports it.
|
||||
- Monty interprets a **subset** of Python (a Rust-based interpreter). Most
|
||||
control flow, common stdlib modules (`sys`, `os`, `typing`, `asyncio`, `re`,
|
||||
`datetime`, `json`), and async functions are supported, but exotic features
|
||||
may not be available. OS-level access (filesystem, network, subprocess) is
|
||||
rejected with `PermissionError` **by default**; mount host directories with
|
||||
`workspace_root` / `file_mounts` to grant scoped filesystem access.
|
||||
- Code is type-checked against tool signatures via
|
||||
[ty](https://docs.astral.sh/ty/) before execution, so wrong argument types
|
||||
surface as a clear error before any host tool runs.
|
||||
- The alpha package is **not** part of `agent-framework[all]` yet, so it must
|
||||
be installed explicitly. Once promoted to beta it will be reachable via the
|
||||
lazy-loading namespace `agent_framework.monty`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._execute_code_tool import MontyExecuteCodeTool
|
||||
from ._provider import MontyCodeActProvider
|
||||
from ._types import FileMount, FileMountInput, MountMode
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"FileMount",
|
||||
"FileMountInput",
|
||||
"MontyCodeActProvider",
|
||||
"MontyExecuteCodeTool",
|
||||
"MountMode",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,558 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""``MontyExecuteCodeTool`` - a ``FunctionTool`` that runs Python in Monty.
|
||||
|
||||
Mirrors the public API of ``HyperlightExecuteCodeTool`` for the subset that
|
||||
applies to a pure-Python interpreter (no backends to choose from). By default
|
||||
the Monty sandbox rejects OS / filesystem / network calls with
|
||||
``PermissionError``; pass ``workspace_root`` or ``file_mounts`` to expose
|
||||
scoped host directories, and the tool will capture any files written under
|
||||
``read-write`` mounts as ``Content`` items in the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from copy import copy
|
||||
from functools import partial
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import Content, FunctionTool
|
||||
from agent_framework._tools import ApprovalMode, normalize_tools
|
||||
|
||||
from ._instructions import build_codeact_instructions, build_execute_code_description
|
||||
from ._monty_bridge import InlineCodeBridge, generate_type_stubs
|
||||
from ._types import FileMount, FileMountInput
|
||||
|
||||
EXECUTE_CODE_TOOL_NAME = "execute_code"
|
||||
EXECUTE_CODE_TOOL_DESCRIPTION = "Execute Python in a Monty interpreter."
|
||||
|
||||
#: Virtual path that the optional ``workspace_root`` directory is mounted at,
|
||||
#: matching the Hyperlight default. Use ``file_mounts`` for any other path.
|
||||
WORKSPACE_MOUNT_PATH = "/input"
|
||||
|
||||
#: Maximum bytes per captured output file. Files larger than this are skipped
|
||||
#: and a ``Content.from_text`` warning is appended in their place.
|
||||
MAX_CAPTURED_FILE_BYTES = 5 * 1024 * 1024 # 5 MiB
|
||||
|
||||
EXECUTE_CODE_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"title": "_ExecuteCodeInput",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"title": "Code",
|
||||
"description": "Python code to execute in a Monty interpreter.",
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
}
|
||||
|
||||
|
||||
def _collect_tools(*tool_groups: Any) -> list[FunctionTool]:
|
||||
"""Merge tool groups, dropping any ``execute_code`` entries and deduping by name."""
|
||||
tools_by_name: dict[str, FunctionTool] = {}
|
||||
|
||||
for tool_group in tool_groups:
|
||||
normalized_group = normalize_tools(tool_group)
|
||||
for tool_obj in normalized_group:
|
||||
if not isinstance(tool_obj, FunctionTool):
|
||||
continue
|
||||
if tool_obj.name == EXECUTE_CODE_TOOL_NAME:
|
||||
continue
|
||||
tools_by_name.pop(tool_obj.name, None)
|
||||
tools_by_name[tool_obj.name] = tool_obj
|
||||
|
||||
return list(tools_by_name.values())
|
||||
|
||||
|
||||
def _resolve_execute_code_approval_mode(
|
||||
*,
|
||||
base_approval_mode: ApprovalMode,
|
||||
tools: Sequence[FunctionTool],
|
||||
) -> ApprovalMode:
|
||||
if base_approval_mode == "always_require":
|
||||
return "always_require"
|
||||
if any(tool_obj.approval_mode == "always_require" for tool_obj in tools):
|
||||
return "always_require"
|
||||
return "never_require"
|
||||
|
||||
|
||||
def _normalize_mount_path(mount_path: str) -> str:
|
||||
"""Normalize a virtual mount path to a clean POSIX absolute path."""
|
||||
raw = mount_path.strip().replace("\\", "/")
|
||||
if not raw:
|
||||
raise ValueError("mount_path must not be empty.")
|
||||
pure = PurePosixPath(raw)
|
||||
parts = [part for part in pure.parts if part not in {"", "/", "."}]
|
||||
if any(part == ".." for part in parts):
|
||||
raise ValueError("mount_path must not contain '..' segments.")
|
||||
if not parts:
|
||||
raise ValueError("mount_path must point to a concrete absolute path.")
|
||||
return "/" + "/".join(parts)
|
||||
|
||||
|
||||
def _resolve_existing_directory(value: str | Path) -> Path:
|
||||
resolved = Path(value).expanduser().resolve(strict=True)
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f"Path {value!r} must point to an existing directory.")
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_file_mount_pair(value: Any) -> bool:
|
||||
if not isinstance(value, tuple) or isinstance(value, FileMount):
|
||||
return False
|
||||
items = cast("tuple[object, ...]", value)
|
||||
if len(items) != 2:
|
||||
return False
|
||||
host_path, mount_path = items
|
||||
return isinstance(host_path, (str, Path)) and isinstance(mount_path, str)
|
||||
|
||||
|
||||
def _normalize_file_mount(file_mount: FileMountInput) -> FileMount:
|
||||
if isinstance(file_mount, FileMount):
|
||||
host_path = file_mount.host_path
|
||||
mount_path = file_mount.mount_path
|
||||
mode = file_mount.mode
|
||||
write_limit = file_mount.write_bytes_limit
|
||||
elif isinstance(file_mount, str):
|
||||
host_path = file_mount
|
||||
mount_path = file_mount
|
||||
mode = "overlay"
|
||||
write_limit = None
|
||||
else:
|
||||
host_path, mount_path = file_mount
|
||||
mode = "overlay"
|
||||
write_limit = None
|
||||
|
||||
return FileMount(
|
||||
host_path=_resolve_existing_directory(host_path),
|
||||
mount_path=_normalize_mount_path(mount_path),
|
||||
mode=mode,
|
||||
write_bytes_limit=write_limit,
|
||||
)
|
||||
|
||||
|
||||
def _to_monty_mount(file_mount: FileMount) -> Any:
|
||||
"""Convert a public :class:`FileMount` to Monty's ``MountDir``.
|
||||
|
||||
Imports lazily through the bridge's loader so missing-dependency errors
|
||||
surface as the same actionable ``RuntimeError`` the rest of the package
|
||||
raises, rather than a bare ``ImportError`` from a top-level import.
|
||||
"""
|
||||
from ._monty_bridge import load_monty # avoid top-level pydantic_monty import
|
||||
|
||||
monty_module = load_monty()
|
||||
return monty_module.MountDir(
|
||||
virtual_path=file_mount.mount_path,
|
||||
host_path=str(file_mount.host_path),
|
||||
mode=file_mount.mode,
|
||||
write_bytes_limit=file_mount.write_bytes_limit,
|
||||
)
|
||||
|
||||
|
||||
def _make_tool_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
|
||||
"""Return an async callable that invokes ``tool_obj`` with the bridge's kwargs.
|
||||
|
||||
Returns the raw native value (no ``Content`` wrapping) so the Monty interpreter
|
||||
receives real Python objects. ``FunctionTool.invoke`` accepts direct keyword
|
||||
arguments and handles both sync and async underlying functions internally.
|
||||
"""
|
||||
return partial(copy(tool_obj).invoke, skip_parsing=True)
|
||||
|
||||
|
||||
class MontyExecuteCodeTool(FunctionTool):
|
||||
"""Execute Python code inside a Monty interpreter.
|
||||
|
||||
Tools registered on this object are available inside the interpreter as
|
||||
typed async functions (e.g. ``await tool_name(...)``). Argument types are
|
||||
validated by the [ty](https://docs.astral.sh/ty/) type checker before any
|
||||
host tool runs.
|
||||
|
||||
Optional filesystem access is exposed via:
|
||||
|
||||
- ``workspace_root`` — auto-mounts a host directory at ``/input`` (matching
|
||||
Hyperlight's default).
|
||||
- ``file_mounts`` — extra :class:`FileMount` entries for fine-grained
|
||||
control (mount path, read-only / read-write / overlay mode, write
|
||||
byte caps).
|
||||
|
||||
Files written by sandboxed code to any **read-write** mount are scanned
|
||||
after execution and returned as ``Content.from_data`` items, mirroring
|
||||
Hyperlight's ``/output`` flow.
|
||||
|
||||
``resource_limits`` is forwarded to Monty's ``ResourceLimits`` to cap CPU
|
||||
time, memory, output size, recursion depth, and GC frequency.
|
||||
|
||||
All mutators (``add_tools``, ``add_file_mounts`` etc.) must be called from
|
||||
the same task/thread that owns the tool. Monty itself runs on the event
|
||||
loop, so no internal locking is needed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
workspace_root: str | Path | None = None,
|
||||
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
|
||||
resource_limits: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=EXECUTE_CODE_TOOL_NAME,
|
||||
description=EXECUTE_CODE_TOOL_DESCRIPTION,
|
||||
approval_mode="never_require",
|
||||
func=self._run_code,
|
||||
input_model=EXECUTE_CODE_INPUT_SCHEMA,
|
||||
)
|
||||
self._default_approval_mode: ApprovalMode = approval_mode or "never_require"
|
||||
self._managed_tools: list[FunctionTool] = []
|
||||
self._workspace_root: Path | None = (
|
||||
_resolve_existing_directory(workspace_root) if workspace_root is not None else None
|
||||
)
|
||||
self._file_mounts: dict[str, FileMount] = {}
|
||||
self._resource_limits: dict[str, Any] | None = dict(resource_limits) if resource_limits else None
|
||||
|
||||
if tools is not None:
|
||||
self.add_tools(tools)
|
||||
if file_mounts is not None:
|
||||
self.add_file_mounts(file_mounts)
|
||||
|
||||
self._refresh_approval_mode()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
# During FunctionTool.__init__, ``_managed_tools`` is not yet set.
|
||||
if not hasattr(self, "_managed_tools"):
|
||||
return str(self.__dict__.get("description", EXECUTE_CODE_TOOL_DESCRIPTION))
|
||||
return build_execute_code_description(
|
||||
tools=self._managed_tools,
|
||||
mounts=self._effective_mounts(),
|
||||
)
|
||||
|
||||
@description.setter
|
||||
def description(self, value: str) -> None:
|
||||
self.__dict__["description"] = value
|
||||
|
||||
def add_tools(
|
||||
self,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
|
||||
) -> None:
|
||||
"""Add Monty-side tools to this execute_code surface."""
|
||||
self._managed_tools = _collect_tools(self._managed_tools, tools)
|
||||
self._refresh_approval_mode()
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""Return the currently managed Monty tools."""
|
||||
return list(self._managed_tools)
|
||||
|
||||
def remove_tool(self, name: str) -> None:
|
||||
"""Remove one managed Monty tool by name."""
|
||||
remaining_tools = [tool_obj for tool_obj in self._managed_tools if tool_obj.name != name]
|
||||
if len(remaining_tools) == len(self._managed_tools):
|
||||
raise KeyError(f"No managed tool named {name!r} is registered.")
|
||||
self._managed_tools = remaining_tools
|
||||
self._refresh_approval_mode()
|
||||
|
||||
def clear_tools(self) -> None:
|
||||
"""Remove all managed Monty tools."""
|
||||
self._managed_tools = []
|
||||
self._refresh_approval_mode()
|
||||
|
||||
def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
|
||||
"""Add one or more file mounts.
|
||||
|
||||
A single string mounts the same path on both sides. Use a
|
||||
``(host_path, mount_path)`` tuple or :class:`FileMount` when the paths
|
||||
differ or when you need to set the mount mode / write limit.
|
||||
"""
|
||||
if isinstance(file_mounts, (str, FileMount)) or _is_file_mount_pair(file_mounts):
|
||||
normalized = [_normalize_file_mount(cast("FileMountInput", file_mounts))]
|
||||
else:
|
||||
normalized = [_normalize_file_mount(item) for item in cast("Sequence[FileMountInput]", file_mounts)]
|
||||
|
||||
for mount in normalized:
|
||||
self._file_mounts[mount.mount_path] = mount
|
||||
|
||||
def get_file_mounts(self) -> list[FileMount]:
|
||||
"""Return the configured file mounts (excluding ``workspace_root``)."""
|
||||
return list(self._file_mounts.values())
|
||||
|
||||
def remove_file_mount(self, mount_path: str) -> None:
|
||||
"""Remove one file mount by its sandbox path."""
|
||||
normalized = _normalize_mount_path(mount_path)
|
||||
if normalized not in self._file_mounts:
|
||||
raise KeyError(f"No file mount exists for {mount_path!r}.")
|
||||
del self._file_mounts[normalized]
|
||||
|
||||
def clear_file_mounts(self) -> None:
|
||||
"""Remove all configured file mounts."""
|
||||
self._file_mounts.clear()
|
||||
|
||||
@property
|
||||
def workspace_root(self) -> Path | None:
|
||||
"""Return the configured workspace root, if any."""
|
||||
return self._workspace_root
|
||||
|
||||
@property
|
||||
def resource_limits(self) -> dict[str, Any] | None:
|
||||
"""Return the configured Monty :class:`pydantic_monty.ResourceLimits`, if any."""
|
||||
return dict(self._resource_limits) if self._resource_limits else None
|
||||
|
||||
def build_instructions(self, *, tools_visible_to_model: bool) -> str:
|
||||
"""Build the current CodeAct instructions for this execute_code surface."""
|
||||
return build_codeact_instructions(
|
||||
tools=list(self._managed_tools),
|
||||
tools_visible_to_model=tools_visible_to_model,
|
||||
mounts=self._effective_mounts(),
|
||||
)
|
||||
|
||||
def create_run_tool(self) -> MontyExecuteCodeTool:
|
||||
"""Create a run-scoped snapshot of this execute_code surface."""
|
||||
return MontyExecuteCodeTool(
|
||||
tools=self.get_tools(),
|
||||
approval_mode=self._default_approval_mode,
|
||||
workspace_root=self._workspace_root,
|
||||
file_mounts=list(self._file_mounts.values()) or None,
|
||||
resource_limits=self._resource_limits,
|
||||
)
|
||||
|
||||
def build_serializable_state(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable snapshot of the effective run state."""
|
||||
approval_mode = _resolve_execute_code_approval_mode(
|
||||
base_approval_mode=self._default_approval_mode,
|
||||
tools=self._managed_tools,
|
||||
)
|
||||
mounts = self._effective_mounts()
|
||||
return {
|
||||
"runtime": "monty",
|
||||
"approval_mode": approval_mode,
|
||||
"tool_names": [tool_obj.name for tool_obj in self._managed_tools],
|
||||
"workspace_root": str(self._workspace_root) if self._workspace_root is not None else None,
|
||||
"file_mounts": [
|
||||
{
|
||||
"host_path": str(mount.host_path),
|
||||
"mount_path": mount.mount_path,
|
||||
"mode": mount.mode,
|
||||
"write_bytes_limit": mount.write_bytes_limit,
|
||||
}
|
||||
for mount in mounts
|
||||
],
|
||||
"resource_limits": dict(self._resource_limits) if self._resource_limits else None,
|
||||
}
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
# Materialize the dynamic description so the dump captures the current tool list.
|
||||
self.__dict__["description"] = self.description
|
||||
return super().to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
|
||||
def _refresh_approval_mode(self) -> None:
|
||||
self.approval_mode = _resolve_execute_code_approval_mode(
|
||||
base_approval_mode=self._default_approval_mode,
|
||||
tools=self._managed_tools,
|
||||
)
|
||||
|
||||
def _build_tool_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]:
|
||||
return {tool_obj.name: _make_tool_callback(tool_obj) for tool_obj in tools}
|
||||
|
||||
def _build_type_stub_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]:
|
||||
"""Return a name -> underlying-Python-callable map for type stub generation.
|
||||
|
||||
The raw Python function attached to the ``FunctionTool`` carries the
|
||||
author's actual ``Annotated`` parameter types, which are what we want
|
||||
``ty`` to validate against. Tools without an attached function (e.g.
|
||||
``declaration_only`` tools) are skipped.
|
||||
"""
|
||||
stub_map: dict[str, Callable[..., Any]] = {}
|
||||
for tool_obj in tools:
|
||||
func = getattr(tool_obj, "func", None)
|
||||
if callable(func):
|
||||
stub_map[tool_obj.name] = func
|
||||
return stub_map
|
||||
|
||||
def _effective_mounts(self) -> list[FileMount]:
|
||||
"""Combine ``workspace_root`` (if set) with the explicit ``file_mounts``."""
|
||||
mounts: list[FileMount] = []
|
||||
if self._workspace_root is not None and WORKSPACE_MOUNT_PATH not in self._file_mounts:
|
||||
mounts.append(
|
||||
FileMount(
|
||||
host_path=self._workspace_root,
|
||||
mount_path=WORKSPACE_MOUNT_PATH,
|
||||
mode="read-write",
|
||||
write_bytes_limit=None,
|
||||
)
|
||||
)
|
||||
mounts.extend(self._file_mounts.values())
|
||||
return mounts
|
||||
|
||||
async def _run_code(self, *, code: str) -> list[Content]:
|
||||
tools = list(self._managed_tools)
|
||||
mounts = self._effective_mounts()
|
||||
|
||||
tool_map = self._build_tool_map(tools)
|
||||
stub_map = self._build_type_stub_map(tools)
|
||||
type_stubs = generate_type_stubs(stub_map) if stub_map else None
|
||||
|
||||
# Snapshot mtimes of host files in read-write mounts so we can later
|
||||
# identify which files the sandbox actually touched.
|
||||
pre_state = _snapshot_writable_mounts(mounts)
|
||||
|
||||
bridge = InlineCodeBridge(
|
||||
tool_map,
|
||||
type_stubs=type_stubs,
|
||||
mounts=[_to_monty_mount(mount) for mount in mounts] or None,
|
||||
resource_limits=self._resource_limits,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await bridge.run(code)
|
||||
except Exception as exc:
|
||||
return [
|
||||
Content.from_error(
|
||||
message="Execution error",
|
||||
error_details=f"{type(exc).__name__}: {exc}",
|
||||
),
|
||||
]
|
||||
|
||||
contents = _build_execution_contents(result=result)
|
||||
contents.extend(_capture_written_files(mounts, pre_state))
|
||||
return contents
|
||||
|
||||
|
||||
def _build_execution_contents(*, result: dict[str, Any]) -> list[Content]:
|
||||
stdout = str(result.get("stdout") or "").replace("\r\n", "\n")
|
||||
output_value = result.get("output")
|
||||
truncated = bool(result.get("truncated"))
|
||||
|
||||
outputs: list[Content] = []
|
||||
if stdout:
|
||||
text = stdout
|
||||
if truncated:
|
||||
text = f"{text}\n\n[stdout truncated]"
|
||||
outputs.append(Content.from_text(text))
|
||||
elif truncated:
|
||||
outputs.append(Content.from_text("[stdout truncated]"))
|
||||
|
||||
if output_value is not None:
|
||||
try:
|
||||
serialized_output = json.dumps(output_value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
serialized_output = repr(output_value)
|
||||
outputs.append(Content.from_text(serialized_output))
|
||||
|
||||
if not outputs:
|
||||
outputs.append(Content.from_text("Code executed successfully without output."))
|
||||
|
||||
return outputs
|
||||
|
||||
|
||||
def _iter_real_files(root: Path) -> Iterator[Path]:
|
||||
"""Walk ``root`` recursively, yielding only real (non-symlink) files.
|
||||
|
||||
``Path.rglob`` follows directory symlinks by default, which combined with
|
||||
``Path.is_file()`` / ``Path.read_bytes()`` (both follow symlinks) would let
|
||||
an attacker who controls the workspace pre-place a symlink to a host file
|
||||
or directory and have our post-execution capture surface it. Skipping every
|
||||
symlink at both the directory and file level closes that escape.
|
||||
"""
|
||||
stack: list[Path] = [root]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
entries = list(current.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
if entry.is_dir():
|
||||
stack.append(entry)
|
||||
elif entry.is_file():
|
||||
yield entry
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _snapshot_writable_mounts(mounts: Sequence[FileMount]) -> dict[str, dict[str, tuple[int, int]]]:
|
||||
"""Capture (size, mtime_ns) for every real (non-symlink) host file under read-write mounts.
|
||||
|
||||
Returns ``{mount_path: {relative_posix_path: (size, mtime_ns)}}``. Used by
|
||||
:func:`_capture_written_files` to detect new or modified files after the run.
|
||||
Read-only and overlay mounts are skipped because their writes do not
|
||||
propagate to the host. Symlinks (file or directory) are deliberately skipped
|
||||
so an attacker cannot escape the mount by pre-placing a symlink to a host
|
||||
path outside the workspace.
|
||||
"""
|
||||
snapshot: dict[str, dict[str, tuple[int, int]]] = {}
|
||||
for mount in mounts:
|
||||
if mount.mode != "read-write":
|
||||
continue
|
||||
host_root = Path(mount.host_path)
|
||||
per_mount: dict[str, tuple[int, int]] = {}
|
||||
for entry in _iter_real_files(host_root):
|
||||
try:
|
||||
stat = entry.lstat() # lstat: never follow symlinks (defensive)
|
||||
except OSError:
|
||||
continue
|
||||
relative = entry.relative_to(host_root).as_posix()
|
||||
per_mount[relative] = (int(stat.st_size), int(stat.st_mtime_ns))
|
||||
snapshot[mount.mount_path] = per_mount
|
||||
return snapshot
|
||||
|
||||
|
||||
def _capture_written_files(
|
||||
mounts: Sequence[FileMount],
|
||||
pre_state: dict[str, dict[str, tuple[int, int]]],
|
||||
) -> list[Content]:
|
||||
"""Return :class:`Content` items for files the sandbox wrote during the run.
|
||||
|
||||
Mirrors Hyperlight's ``/output`` capture flow: any new or modified real
|
||||
(non-symlink) file under a read-write mount is read back as binary and
|
||||
surfaced as ``Content.from_data`` with a ``path`` annotation in
|
||||
``additional_properties``. Symlinks are skipped at both directory and file
|
||||
level so a malicious workspace cannot trick us into capturing host files
|
||||
outside the configured mount root.
|
||||
"""
|
||||
captured: list[Content] = []
|
||||
for mount in mounts:
|
||||
if mount.mode != "read-write":
|
||||
continue
|
||||
host_root = Path(mount.host_path)
|
||||
before = pre_state.get(mount.mount_path, {})
|
||||
for entry in sorted(_iter_real_files(host_root)):
|
||||
try:
|
||||
stat = entry.lstat()
|
||||
except OSError:
|
||||
continue
|
||||
relative = entry.relative_to(host_root).as_posix()
|
||||
current = (int(stat.st_size), int(stat.st_mtime_ns))
|
||||
if before.get(relative) == current:
|
||||
continue # Unchanged.
|
||||
sandbox_path = f"{mount.mount_path.rstrip('/')}/{relative}"
|
||||
if stat.st_size > MAX_CAPTURED_FILE_BYTES:
|
||||
captured.append(
|
||||
Content.from_text(
|
||||
f"[file {sandbox_path} omitted: {stat.st_size} bytes "
|
||||
f"exceeds MAX_CAPTURED_FILE_BYTES={MAX_CAPTURED_FILE_BYTES}]"
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
# _iter_real_files already excluded symlinks at every level of
|
||||
# the walk; reading the file here is safe.
|
||||
data = entry.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
media_type = mimetypes.guess_type(entry.name)[0] or "application/octet-stream"
|
||||
captured.append(
|
||||
Content.from_data(
|
||||
data=data,
|
||||
media_type=media_type,
|
||||
additional_properties={"path": sandbox_path},
|
||||
)
|
||||
)
|
||||
return captured
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Dynamic CodeAct instructions and execute_code tool descriptions for Monty."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
from ._types import FileMount
|
||||
|
||||
|
||||
def _format_tool_summaries(tools: Sequence[FunctionTool]) -> str:
|
||||
if not tools:
|
||||
return "- No tools are currently registered."
|
||||
|
||||
lines: list[str] = []
|
||||
for tool_obj in tools:
|
||||
parameters = tool_obj.parameters().get("properties", {})
|
||||
parameter_names = [name for name in parameters if isinstance(name, str)]
|
||||
parameter_summary = ", ".join(parameter_names) if parameter_names else "none"
|
||||
description = str(tool_obj.description or "").strip() or "No description provided."
|
||||
lines.append(f"- `{tool_obj.name}`: {description} Parameters: {parameter_summary}.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_filesystem_capabilities(mounts: Sequence[FileMount]) -> str:
|
||||
if not mounts:
|
||||
return (
|
||||
"Filesystem access is unavailable. OS-level paths raise `PermissionError`. "
|
||||
"If you need files, ask the agent operator to configure `workspace_root` or `file_mounts`."
|
||||
)
|
||||
|
||||
lines = ["Filesystem access is enabled. Read and write paths via `pathlib.Path(...)` (or `os.path`)."]
|
||||
lines.append("Configured mounts:")
|
||||
for mount in mounts:
|
||||
cap = ""
|
||||
if mount.write_bytes_limit is not None:
|
||||
cap = f", write cap {mount.write_bytes_limit} bytes"
|
||||
lines.append(f"- `{mount.mount_path}` ({mount.mode}{cap})")
|
||||
|
||||
writable = [mount for mount in mounts if mount.mode == "read-write"]
|
||||
if writable:
|
||||
writable_paths = ", ".join(f"`{m.mount_path}`" for m in writable)
|
||||
lines.append(
|
||||
f"Files written to {writable_paths} are returned to the caller as attached files; "
|
||||
"use these paths for any output artifacts."
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_codeact_instructions(
|
||||
*,
|
||||
tools: Sequence[FunctionTool],
|
||||
tools_visible_to_model: bool,
|
||||
mounts: Sequence[FileMount] = (),
|
||||
) -> str:
|
||||
"""Build dynamic CodeAct instructions for the effective Monty tool set."""
|
||||
tool_summaries = _format_tool_summaries(tools)
|
||||
filesystem_text = _format_filesystem_capabilities(mounts)
|
||||
|
||||
usage_note = (
|
||||
"Some tools may also appear directly, but prefer `execute_code` whenever you need to combine "
|
||||
"Python control flow with sandbox tool calls."
|
||||
if tools_visible_to_model
|
||||
else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them."
|
||||
)
|
||||
|
||||
return f"""You have one primary tool: `execute_code`.
|
||||
|
||||
Inside `execute_code`, call registered tools directly as async functions:
|
||||
`result = await tool_name(param=value)`. Always use `await` and keyword arguments.
|
||||
Your code is type-checked against the tool signatures below before execution.
|
||||
`await call_tool('name', **kwargs)` is also supported as a fallback but is not type-checked.
|
||||
|
||||
For fan-out, use `asyncio.gather`:
|
||||
`results = await asyncio.gather(tool_a(...), tool_b(...))`.
|
||||
|
||||
Surface results to the caller via `print(...)` (captured and returned as text)
|
||||
or by ending the code with an expression whose value is JSON-encodable - the
|
||||
value of the final expression is returned alongside captured stdout.
|
||||
|
||||
Filesystem capabilities:
|
||||
{filesystem_text}
|
||||
|
||||
Registered tools:
|
||||
{tool_summaries}
|
||||
|
||||
Prefer a single `execute_code` call per request when possible, combining
|
||||
multiple tool calls with Python control flow.
|
||||
|
||||
{usage_note}
|
||||
"""
|
||||
|
||||
|
||||
def build_execute_code_description(
|
||||
*,
|
||||
tools: Sequence[FunctionTool],
|
||||
mounts: Sequence[FileMount] = (),
|
||||
) -> str:
|
||||
"""Build the dynamic ``execute_code`` tool description for standalone usage."""
|
||||
tool_summaries = _format_tool_summaries(tools)
|
||||
filesystem_text = _format_filesystem_capabilities(mounts)
|
||||
|
||||
return f"""Execute Python code in a Monty interpreter.
|
||||
|
||||
Inside the sandbox, call registered tools directly as typed async functions:
|
||||
`result = await tool_name(param=value)`. Always use `await` and keyword arguments.
|
||||
Code is type-checked against tool signatures before execution.
|
||||
`await call_tool('name', **kwargs)` is also supported as a fallback.
|
||||
|
||||
For fan-out, use `asyncio.gather`:
|
||||
`results = await asyncio.gather(tool_a(...), tool_b(...))`.
|
||||
|
||||
Filesystem capabilities:
|
||||
{filesystem_text}
|
||||
|
||||
Registered tools:
|
||||
{tool_summaries}
|
||||
|
||||
Surface results via `print(...)` (captured and returned as text) or by ending
|
||||
with an expression whose value is JSON-encodable.
|
||||
"""
|
||||
@@ -0,0 +1,327 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Inline (non-durable) Monty execution bridge and type-stub generation.
|
||||
|
||||
Adapted from https://github.com/anthonychu/maf-codeact-monty-python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import keyword
|
||||
import types
|
||||
import typing
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Annotated, Any, cast, get_type_hints
|
||||
|
||||
MAX_PRINT_OUTPUT_CHARS = 8192
|
||||
|
||||
# Prelude injected into all Monty code so `asyncio.gather` works for fan-out.
|
||||
_CODEACT_PRELUDE = """\
|
||||
import asyncio
|
||||
"""
|
||||
|
||||
|
||||
def _ensure_json_value(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
raise ValueError("Non-finite floating point values are not JSON-safe.")
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = cast("list[object] | tuple[object, ...]", value)
|
||||
return [_ensure_json_value(item) for item in items]
|
||||
if isinstance(value, dict):
|
||||
as_dict = cast("dict[object, object]", value)
|
||||
return {str(k): _ensure_json_value(v) for k, v in as_dict.items()}
|
||||
raise ValueError(f"Value of type {type(value).__name__} is not JSON-safe.")
|
||||
|
||||
|
||||
def _external_error(exc: Exception) -> dict[str, str]:
|
||||
return {"exc_type": type(exc).__name__, "message": str(exc)}
|
||||
|
||||
|
||||
def _parse_call_tool(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
if not args:
|
||||
raise ValueError("call_tool requires a tool name as the first argument.")
|
||||
name = args[0]
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError("Tool name must be a non-empty string.")
|
||||
if len(args) > 1:
|
||||
raise ValueError(
|
||||
"call_tool accepts only the tool name as a positional argument. Use keyword arguments for parameters."
|
||||
)
|
||||
return name, dict(kwargs)
|
||||
|
||||
|
||||
def _build_code(code: str) -> str:
|
||||
return f"{_CODEACT_PRELUDE}\n{code}"
|
||||
|
||||
|
||||
def _python_type_repr(annotation: Any) -> str:
|
||||
"""Convert a Python type annotation to its string representation for stubs."""
|
||||
if annotation is inspect.Parameter.empty:
|
||||
return "Any"
|
||||
if annotation is type(None):
|
||||
# ``None`` in annotations represents ``NoneType``; emit it literally so
|
||||
# ``ty`` can validate ``Optional[X]`` / ``Union[..., None]`` / ``-> None``
|
||||
# signatures correctly.
|
||||
return "None"
|
||||
origin = typing.get_origin(annotation)
|
||||
if origin is Annotated:
|
||||
args = typing.get_args(annotation)
|
||||
return _python_type_repr(args[0]) if args else "Any"
|
||||
if origin is not None:
|
||||
args = typing.get_args(annotation)
|
||||
# Normalize ``typing.Union[...]`` and PEP-604 ``X | Y`` to PEP-604 syntax so
|
||||
# ``None`` is preserved across both forms.
|
||||
if origin is typing.Union or origin is types.UnionType:
|
||||
return " | ".join(_python_type_repr(a) for a in args) if args else "Any"
|
||||
origin_name = getattr(origin, "__name__", None)
|
||||
if origin_name is None:
|
||||
origin_name = str(origin)
|
||||
if origin_name.startswith("<class '"):
|
||||
origin_name = origin_name[8:-2]
|
||||
if args:
|
||||
arg_strs = ", ".join(_python_type_repr(a) for a in args)
|
||||
return f"{origin_name}[{arg_strs}]"
|
||||
return origin_name
|
||||
if hasattr(annotation, "__name__"):
|
||||
return str(annotation.__name__)
|
||||
return str(annotation)
|
||||
|
||||
|
||||
def generate_type_stubs(tool_callables: dict[str, Callable[..., Any]]) -> str:
|
||||
"""Generate Python type stub declarations for tools + DSL primitives.
|
||||
|
||||
Stubs are fed to Monty's ``type_check_stubs`` so ``ty`` can validate the
|
||||
LLM-generated code against the actual tool signatures before any host
|
||||
call runs.
|
||||
|
||||
Tools whose ``name`` is not a valid Python identifier are skipped because
|
||||
their name cannot be safely splatted into stub source. The model can still
|
||||
reach them via the ``call_tool("weird name", ...)`` fallback at runtime,
|
||||
but they will not get type-checked stubs.
|
||||
"""
|
||||
lines: list[str] = [
|
||||
"from typing import Any",
|
||||
"",
|
||||
"# DSL primitives",
|
||||
"async def call_tool(name: str, **kwargs: Any) -> Any:",
|
||||
" raise NotImplementedError()",
|
||||
"",
|
||||
"# Registered tools - call directly with typed arguments",
|
||||
]
|
||||
|
||||
for name, func in sorted(tool_callables.items()):
|
||||
if not name.isidentifier() or keyword.iskeyword(name):
|
||||
# A non-identifier name (or a Python keyword) would inject invalid
|
||||
# / dangerous syntax into the stub source. Skip stub generation;
|
||||
# the tool stays reachable through ``call_tool(name, ...)``.
|
||||
continue
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
hints = get_type_hints(func, include_extras=True)
|
||||
except (ValueError, TypeError):
|
||||
lines.append(f"async def {name}(**kwargs: Any) -> Any:")
|
||||
lines.append(" raise NotImplementedError()")
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
params: list[str] = []
|
||||
for param_name, param in sig.parameters.items():
|
||||
annotation = hints.get(param_name, inspect.Parameter.empty)
|
||||
type_str = _python_type_repr(annotation)
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
params.append(f"{param_name}: {type_str} = ...")
|
||||
else:
|
||||
params.append(f"{param_name}: {type_str}")
|
||||
|
||||
return_annotation = hints.get("return", inspect.Parameter.empty)
|
||||
return_str = _python_type_repr(return_annotation)
|
||||
param_str = ", ".join(params)
|
||||
lines.append(f"async def {name}({param_str}) -> {return_str}:")
|
||||
lines.append(" raise NotImplementedError()")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class _PrintCollector:
|
||||
"""Collect Monty stdout, capped at ``MAX_PRINT_OUTPUT_CHARS``."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.chunks: list[str] = []
|
||||
self.truncated: bool = False
|
||||
self._size: int = 0 # running character count to avoid O(n) per append
|
||||
|
||||
def __call__(self, stream: str, text: str) -> None:
|
||||
if self.truncated:
|
||||
return
|
||||
remaining = MAX_PRINT_OUTPUT_CHARS - self._size
|
||||
if remaining <= 0:
|
||||
self.truncated = True
|
||||
return
|
||||
text_value = str(text)
|
||||
if len(text_value) > remaining:
|
||||
clipped = text_value[:remaining]
|
||||
self.chunks.append(clipped)
|
||||
self._size += len(clipped)
|
||||
self.truncated = True
|
||||
else:
|
||||
self.chunks.append(text_value)
|
||||
self._size += len(text_value)
|
||||
|
||||
@property
|
||||
def output(self) -> str:
|
||||
return "".join(self.chunks)
|
||||
|
||||
|
||||
def load_monty() -> Any:
|
||||
"""Import ``pydantic_monty`` lazily so unit tests can run without it.
|
||||
|
||||
Returns the module so callers can read ``Monty``, ``MontyComplete``,
|
||||
``FunctionSnapshot``, ``FutureSnapshot``, ``NameLookupSnapshot`` from it.
|
||||
"""
|
||||
try:
|
||||
import pydantic_monty # type: ignore[import-not-found]
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"The `pydantic-monty` package is required to execute Monty CodeAct code. "
|
||||
"Install it with `pip install pydantic-monty`."
|
||||
) from exc
|
||||
return pydantic_monty
|
||||
|
||||
|
||||
class InlineCodeBridge:
|
||||
"""Execute Monty code inline (non-durable).
|
||||
|
||||
Supports both ``await call_tool('name', ...)`` and direct ``await name(...)``
|
||||
calls. When Monty yields a :class:`FutureSnapshot`, the bridge invokes the
|
||||
registered host tools and resumes execution with the results.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tool_map: dict[str, Callable[..., Any]],
|
||||
*,
|
||||
type_stubs: str | None = None,
|
||||
mounts: Sequence[Any] | None = None,
|
||||
resource_limits: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.tool_map: dict[str, Callable[..., Any]] = dict(tool_map)
|
||||
self.type_stubs: str | None = type_stubs
|
||||
self._mounts = tuple(mounts) if mounts else ()
|
||||
self._resource_limits = resource_limits
|
||||
self._pending_calls: dict[int, tuple[str, dict[str, Any]]] = {}
|
||||
|
||||
async def run(self, code: str) -> dict[str, Any]:
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
raise ValueError("Code must be a non-empty string.")
|
||||
|
||||
monty_module = load_monty()
|
||||
Monty = monty_module.Monty
|
||||
MontyComplete = monty_module.MontyComplete
|
||||
FunctionSnapshot = monty_module.FunctionSnapshot
|
||||
FutureSnapshot = monty_module.FutureSnapshot
|
||||
NameLookupSnapshot = monty_module.NameLookupSnapshot
|
||||
|
||||
printer = _PrintCollector()
|
||||
monty = Monty(
|
||||
_build_code(code),
|
||||
script_name="codeact.py",
|
||||
type_check=self.type_stubs is not None,
|
||||
type_check_stubs=self.type_stubs,
|
||||
)
|
||||
start_kwargs: dict[str, Any] = {"print_callback": printer}
|
||||
if self._mounts:
|
||||
start_kwargs["mount"] = list(self._mounts)
|
||||
if self._resource_limits:
|
||||
start_kwargs["limits"] = self._resource_limits
|
||||
progress = monty.start(**start_kwargs)
|
||||
|
||||
while True:
|
||||
if isinstance(progress, MontyComplete):
|
||||
return {
|
||||
"output": _ensure_json_value(progress.output),
|
||||
"stdout": printer.output,
|
||||
"truncated": printer.truncated,
|
||||
}
|
||||
if isinstance(progress, FunctionSnapshot):
|
||||
progress = self._handle_function(progress)
|
||||
continue
|
||||
if isinstance(progress, FutureSnapshot):
|
||||
progress = await self._handle_future(progress)
|
||||
continue
|
||||
if isinstance(progress, NameLookupSnapshot):
|
||||
raise RuntimeError(f"Name lookup not supported: {progress.variable_name!r}")
|
||||
raise RuntimeError(f"Unsupported Monty progress type: {type(progress).__name__}")
|
||||
|
||||
def _handle_function(self, snapshot: Any) -> Any:
|
||||
if snapshot.is_os_function:
|
||||
return snapshot.resume({
|
||||
"exc_type": "PermissionError",
|
||||
"message": "OS and filesystem calls are not available.",
|
||||
})
|
||||
|
||||
function_name = str(snapshot.function_name)
|
||||
|
||||
if function_name in self.tool_map:
|
||||
return self._schedule_direct_tool(snapshot, function_name)
|
||||
if function_name == "call_tool":
|
||||
return self._schedule_call_tool(snapshot)
|
||||
|
||||
return snapshot.resume({
|
||||
"exc_type": "NameError",
|
||||
"message": f"Function {function_name!r} is not available.",
|
||||
})
|
||||
|
||||
def _schedule_direct_tool(self, snapshot: Any, name: str) -> Any:
|
||||
# Positional args are rejected up-front by ``ty`` because the generated
|
||||
# stubs declare every parameter as keyword-typed. Anything that slips
|
||||
# through (e.g. tools with no signature inspection) is forwarded to the
|
||||
# host tool as-is via kwargs only.
|
||||
self._pending_calls[int(snapshot.call_id)] = (name, dict(snapshot.kwargs))
|
||||
return snapshot.resume({"future": ...})
|
||||
|
||||
def _schedule_call_tool(self, snapshot: Any) -> Any:
|
||||
try:
|
||||
name, kwargs = _parse_call_tool(snapshot.args, snapshot.kwargs)
|
||||
if name not in self.tool_map:
|
||||
allowed = ", ".join(sorted(self.tool_map.keys())) or "<none>"
|
||||
raise ValueError(f"Tool {name!r} is not registered. Available tools: {allowed}")
|
||||
self._pending_calls[int(snapshot.call_id)] = (name, kwargs)
|
||||
except Exception as exc:
|
||||
return snapshot.resume(_external_error(exc))
|
||||
return snapshot.resume({"future": ...})
|
||||
|
||||
async def _handle_future(self, snapshot: Any) -> Any:
|
||||
pending_call_ids = [int(cid) for cid in snapshot.pending_call_ids]
|
||||
if not pending_call_ids:
|
||||
return snapshot.resume({})
|
||||
|
||||
entries: list[tuple[int, tuple[str, dict[str, Any]]]] = []
|
||||
for cid in pending_call_ids:
|
||||
if cid not in self._pending_calls:
|
||||
raise RuntimeError(f"Unknown future call ID: {cid}")
|
||||
entries.append((cid, self._pending_calls.pop(cid)))
|
||||
|
||||
tasks = [self._invoke_tool(cid, name, kwargs) for cid, (name, kwargs) in entries]
|
||||
results = await asyncio.gather(*tasks)
|
||||
resume_results: dict[int, Any] = dict(results)
|
||||
return snapshot.resume(resume_results)
|
||||
|
||||
async def _invoke_tool(self, cid: int, name: str, kwargs: dict[str, Any]) -> tuple[int, Any]:
|
||||
# Every entry in ``self.tool_map`` is produced by ``_make_tool_callback``
|
||||
# as ``partial(FunctionTool.invoke, skip_parsing=True)``. ``FunctionTool.invoke``
|
||||
# is always ``async def``, so a plain ``await`` is correct for every call and
|
||||
# avoids relying on ``inspect.iscoroutinefunction(partial(...))``, which can
|
||||
# return ``False`` for some ``partial`` shapes (cpython#98590) and would route
|
||||
# the call through ``asyncio.to_thread`` with an unawaited coroutine return.
|
||||
try:
|
||||
result = await self.tool_map[name](**kwargs)
|
||||
return cid, {"return_value": _ensure_json_value(result)}
|
||||
except Exception as exc:
|
||||
return cid, _external_error(exc)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""``MontyCodeActProvider`` - context provider injecting Monty-backed CodeAct."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext
|
||||
from agent_framework._tools import ApprovalMode
|
||||
|
||||
from ._execute_code_tool import MontyExecuteCodeTool
|
||||
from ._types import FileMount, FileMountInput
|
||||
|
||||
|
||||
class MontyCodeActProvider(ContextProvider):
|
||||
"""Inject a Monty-backed CodeAct surface using provider-owned tools.
|
||||
|
||||
Mirrors :class:`agent_framework_hyperlight.HyperlightCodeActProvider` for
|
||||
the subset of capabilities that apply to the Monty interpreter:
|
||||
``tools``, ``approval_mode``, ``workspace_root``, ``file_mounts``, and
|
||||
``resource_limits`` (Monty-only).
|
||||
"""
|
||||
|
||||
DEFAULT_SOURCE_ID = "monty_codeact"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
*,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
workspace_root: str | Path | None = None,
|
||||
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
|
||||
resource_limits: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(source_id)
|
||||
self._execute_code_tool = MontyExecuteCodeTool(
|
||||
tools=tools,
|
||||
approval_mode=approval_mode,
|
||||
workspace_root=workspace_root,
|
||||
file_mounts=file_mounts,
|
||||
resource_limits=resource_limits,
|
||||
)
|
||||
|
||||
def add_tools(
|
||||
self,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
|
||||
) -> None:
|
||||
"""Add provider-owned Monty tools."""
|
||||
self._execute_code_tool.add_tools(tools)
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""Return the provider-owned Monty tools."""
|
||||
return self._execute_code_tool.get_tools()
|
||||
|
||||
def remove_tool(self, name: str) -> None:
|
||||
"""Remove one provider-owned Monty tool by name."""
|
||||
self._execute_code_tool.remove_tool(name)
|
||||
|
||||
def clear_tools(self) -> None:
|
||||
"""Remove all provider-owned Monty tools."""
|
||||
self._execute_code_tool.clear_tools()
|
||||
|
||||
def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
|
||||
"""Add provider-managed file mounts."""
|
||||
self._execute_code_tool.add_file_mounts(file_mounts)
|
||||
|
||||
def get_file_mounts(self) -> list[FileMount]:
|
||||
"""Return the provider-managed file mounts (excluding ``workspace_root``)."""
|
||||
return self._execute_code_tool.get_file_mounts()
|
||||
|
||||
def remove_file_mount(self, mount_path: str) -> None:
|
||||
"""Remove one provider-managed file mount by its sandbox path."""
|
||||
self._execute_code_tool.remove_file_mount(mount_path)
|
||||
|
||||
def clear_file_mounts(self) -> None:
|
||||
"""Remove all provider-managed file mounts."""
|
||||
self._execute_code_tool.clear_file_mounts()
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession | None,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject CodeAct instructions and a run-scoped execute_code tool before each run."""
|
||||
run_tool = self._execute_code_tool.create_run_tool()
|
||||
state[self.source_id] = run_tool.build_serializable_state()
|
||||
context.extend_instructions(self.source_id, run_tool.build_instructions(tools_visible_to_model=False))
|
||||
context.extend_tools(self.source_id, [run_tool])
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Public types for ``agent-framework-monty``.
|
||||
|
||||
Mirrors ``agent_framework_hyperlight._types`` where the Monty runtime exposes
|
||||
an equivalent concept so users can move between the two providers with minimal
|
||||
churn.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal, NamedTuple, TypeAlias
|
||||
|
||||
#: Allowed Monty mount modes. ``overlay`` (the Monty default) buffers writes
|
||||
#: in-memory and is therefore not visible to the host after execution.
|
||||
#: ``read-only`` rejects writes. ``read-write`` writes through to the host
|
||||
#: directory.
|
||||
MountMode: TypeAlias = Literal["overlay", "read-only", "read-write"]
|
||||
|
||||
|
||||
class FileMount(NamedTuple):
|
||||
"""Map a host directory into the Monty sandbox.
|
||||
|
||||
Mirrors :class:`agent_framework_hyperlight.FileMount` with two extra
|
||||
fields that surface Monty's underlying ``MountDir`` capabilities:
|
||||
``mode`` selects read-only / read-write / overlay semantics, and
|
||||
``write_bytes_limit`` caps the total bytes written through this mount.
|
||||
"""
|
||||
|
||||
host_path: str | Path
|
||||
mount_path: str
|
||||
mode: MountMode = "overlay"
|
||||
write_bytes_limit: int | None = None
|
||||
|
||||
|
||||
FileMountHostPath: TypeAlias = str | Path
|
||||
FileMountInput: TypeAlias = str | tuple[FileMountHostPath, str] | FileMount
|
||||
@@ -0,0 +1,107 @@
|
||||
[project]
|
||||
name = "agent-framework-monty"
|
||||
description = "Monty CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260518"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"pydantic-monty>=0,<0.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_monty"]
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_monty"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_monty"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_monty --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[tool.poe.tasks.test-integration]
|
||||
help = "Run integration tests for this package (requires pydantic-monty)."
|
||||
cmd = 'pytest -m "integration" tests'
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework_monty"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,642 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Hermetic unit tests for ``agent_framework_monty``.
|
||||
|
||||
These tests inject a fake Monty runtime via ``monkeypatch`` so they run without
|
||||
the real ``pydantic-monty`` package doing any work. End-to-end tests against
|
||||
the real runtime live in ``test_monty_codeact_integration.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import Content, FunctionTool, Message, tool
|
||||
from agent_framework._sessions import SessionContext
|
||||
|
||||
from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool
|
||||
from agent_framework_monty import _execute_code_tool as execute_code_module
|
||||
from agent_framework_monty import _monty_bridge as bridge_module
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake Monty runtime - drop-in replacement for pydantic_monty
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMontyComplete:
|
||||
output: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeFunctionSnapshot:
|
||||
function_name: str
|
||||
call_id: int
|
||||
args: tuple[Any, ...] = ()
|
||||
kwargs: dict[str, Any] = field(default_factory=dict)
|
||||
is_os_function: bool = False
|
||||
_script: _FakeScript | None = None
|
||||
|
||||
def resume(self, payload: Any) -> Any:
|
||||
assert self._script is not None, "Snapshot must be attached to a script."
|
||||
return self._script.advance(("function_resume", self, payload))
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeFutureSnapshot:
|
||||
pending_call_ids: list[int]
|
||||
_script: _FakeScript | None = None
|
||||
|
||||
def resume(self, payload: Any) -> Any:
|
||||
assert self._script is not None, "Snapshot must be attached to a script."
|
||||
return self._script.advance(("future_resume", self, payload))
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeNameLookupSnapshot:
|
||||
variable_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PrintAction:
|
||||
"""Marker pushed onto a script to emit captured stdout via the print callback."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
class _FakeScript:
|
||||
"""Replayable Monty progress script with a resume log."""
|
||||
|
||||
def __init__(self, items: Iterable[Any]) -> None:
|
||||
self._queue: list[Any] = list(items)
|
||||
self.resume_log: list[tuple[str, Any, Any]] = []
|
||||
|
||||
def attach(self, snapshot: Any) -> Any:
|
||||
snapshot._script = self
|
||||
return snapshot
|
||||
|
||||
def next_item(self) -> Any:
|
||||
if not self._queue:
|
||||
return _FakeMontyComplete(output=None)
|
||||
item = self._queue.pop(0)
|
||||
if isinstance(item, _FakeMontyComplete):
|
||||
return item
|
||||
if isinstance(item, _PrintAction):
|
||||
return item
|
||||
if isinstance(item, _FakeNameLookupSnapshot):
|
||||
return item
|
||||
return self.attach(item)
|
||||
|
||||
def advance(self, log_entry: tuple[str, Any, Any]) -> Any:
|
||||
self.resume_log.append(log_entry)
|
||||
return self.next_item()
|
||||
|
||||
|
||||
_current_script: list[_FakeScript | None] = [None]
|
||||
|
||||
|
||||
def _set_script(*items: Any) -> _FakeScript:
|
||||
script = _FakeScript(items)
|
||||
_current_script[0] = script
|
||||
return script
|
||||
|
||||
|
||||
def _get_script() -> _FakeScript:
|
||||
script = _current_script[0]
|
||||
assert script is not None, "Test must call _set_script(...) before running code."
|
||||
return script
|
||||
|
||||
|
||||
class _FakeMonty:
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
*,
|
||||
script_name: str,
|
||||
type_check: bool,
|
||||
type_check_stubs: str | None,
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.script_name = script_name
|
||||
self.type_check = type_check
|
||||
self.type_check_stubs = type_check_stubs
|
||||
self._script = _get_script()
|
||||
|
||||
def start(self, *, print_callback: Any) -> Any:
|
||||
while True:
|
||||
item = self._script.next_item()
|
||||
if isinstance(item, _PrintAction):
|
||||
print_callback("stdout", item.text)
|
||||
continue
|
||||
return item
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_monty_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""Install a fake ``pydantic_monty`` module for the duration of each test."""
|
||||
fake = types.ModuleType("pydantic_monty")
|
||||
fake.Monty = _FakeMonty # type: ignore[attr-defined]
|
||||
fake.MontyComplete = _FakeMontyComplete # type: ignore[attr-defined]
|
||||
fake.FunctionSnapshot = _FakeFunctionSnapshot # type: ignore[attr-defined]
|
||||
fake.FutureSnapshot = _FakeFutureSnapshot # type: ignore[attr-defined]
|
||||
fake.NameLookupSnapshot = _FakeNameLookupSnapshot # type: ignore[attr-defined]
|
||||
|
||||
monkeypatch.setitem(sys.modules, "pydantic_monty", fake)
|
||||
_current_script[0] = None
|
||||
yield
|
||||
_current_script[0] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample tools used across tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@tool
|
||||
def add_tool(
|
||||
a: Annotated[int, "First addend"],
|
||||
b: Annotated[int, "Second addend"],
|
||||
) -> int:
|
||||
"""Add two integers."""
|
||||
return a + b
|
||||
|
||||
|
||||
@tool
|
||||
def mul_tool(
|
||||
a: Annotated[int, "First factor"],
|
||||
b: Annotated[int, "Second factor"],
|
||||
) -> int:
|
||||
"""Multiply two integers."""
|
||||
return a * b
|
||||
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous_tool(payload: Annotated[str, "Anything"]) -> str:
|
||||
"""A tool that always requires approval."""
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MontyExecuteCodeTool tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tool_construction_defaults() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
assert monty_tool.name == "execute_code"
|
||||
assert monty_tool.approval_mode == "never_require"
|
||||
assert monty_tool.get_tools() == []
|
||||
|
||||
|
||||
def test_add_remove_clear_tools_round_trip() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
|
||||
monty_tool.add_tools([add_tool, mul_tool])
|
||||
assert [t.name for t in monty_tool.get_tools()] == ["add_tool", "mul_tool"]
|
||||
|
||||
monty_tool.remove_tool("add_tool")
|
||||
assert [t.name for t in monty_tool.get_tools()] == ["mul_tool"]
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
monty_tool.remove_tool("missing")
|
||||
|
||||
monty_tool.clear_tools()
|
||||
assert monty_tool.get_tools() == []
|
||||
|
||||
|
||||
def test_approval_required_tool_gates_execute_code() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
assert monty_tool.approval_mode == "never_require"
|
||||
|
||||
monty_tool.add_tools([dangerous_tool])
|
||||
assert monty_tool.approval_mode == "always_require"
|
||||
|
||||
monty_tool.remove_tool("dangerous_tool")
|
||||
assert monty_tool.approval_mode == "never_require"
|
||||
|
||||
|
||||
def test_default_approval_mode_always_require_is_sticky() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool], approval_mode="always_require")
|
||||
assert monty_tool.approval_mode == "always_require"
|
||||
|
||||
monty_tool.clear_tools()
|
||||
assert monty_tool.approval_mode == "always_require"
|
||||
|
||||
|
||||
def test_dynamic_description_reflects_registered_tools() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
description = monty_tool.description
|
||||
assert "add_tool" in description
|
||||
assert "Monty" in description
|
||||
|
||||
monty_tool.add_tools([mul_tool])
|
||||
description_updated = monty_tool.description
|
||||
assert "mul_tool" in description_updated
|
||||
|
||||
|
||||
def test_create_run_tool_snapshots_current_state() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool], approval_mode="never_require")
|
||||
run_tool = monty_tool.create_run_tool()
|
||||
|
||||
assert run_tool is not monty_tool
|
||||
assert [t.name for t in run_tool.get_tools()] == ["add_tool"]
|
||||
assert run_tool.approval_mode == monty_tool.approval_mode
|
||||
|
||||
# Mutating the original must not leak into the snapshot.
|
||||
monty_tool.add_tools([mul_tool])
|
||||
assert [t.name for t in run_tool.get_tools()] == ["add_tool"]
|
||||
|
||||
|
||||
def test_build_serializable_state_matches_effective_config() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool, dangerous_tool])
|
||||
state = monty_tool.build_serializable_state()
|
||||
assert state["runtime"] == "monty"
|
||||
assert state["approval_mode"] == "always_require"
|
||||
assert set(state["tool_names"]) == {"add_tool", "dangerous_tool"}
|
||||
assert state["workspace_root"] is None
|
||||
assert state["file_mounts"] == []
|
||||
assert state["resource_limits"] is None
|
||||
|
||||
|
||||
def test_file_mounts_normalized_and_round_tripped(tmp_path: Path) -> None:
|
||||
from agent_framework_monty import FileMount
|
||||
from agent_framework_monty._execute_code_tool import _normalize_mount_path
|
||||
|
||||
host_a = tmp_path / "a"
|
||||
host_a.mkdir()
|
||||
host_b = tmp_path / "b"
|
||||
host_b.mkdir()
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(
|
||||
file_mounts=[
|
||||
str(host_a), # shorthand: same path on both sides
|
||||
(str(host_b), "/work"), # explicit tuple
|
||||
FileMount(host_path=host_a, mount_path="/data", mode="read-only"),
|
||||
],
|
||||
)
|
||||
|
||||
mounts = monty_tool.get_file_mounts()
|
||||
by_mount = {m.mount_path: m for m in mounts}
|
||||
|
||||
# The shorthand string is normalized through _normalize_mount_path (POSIX-style),
|
||||
# so on Windows `C:\\...` becomes `/C:/...`. Compare against the same normalizer.
|
||||
shorthand_key = _normalize_mount_path(str(host_a))
|
||||
assert set(by_mount) == {shorthand_key, "/work", "/data"}
|
||||
assert by_mount["/work"].host_path == host_b.resolve()
|
||||
assert by_mount["/data"].mode == "read-only"
|
||||
assert by_mount[shorthand_key].mode == "overlay" # default
|
||||
|
||||
|
||||
def test_workspace_root_auto_mounts_at_input(tmp_path: Path) -> None:
|
||||
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
|
||||
mounts = monty_tool._effective_mounts()
|
||||
assert any(m.mount_path == "/input" and m.mode == "read-write" for m in mounts)
|
||||
|
||||
|
||||
def test_workspace_root_yields_to_explicit_input_mount(tmp_path: Path) -> None:
|
||||
from agent_framework_monty import FileMount
|
||||
|
||||
explicit = tmp_path / "explicit"
|
||||
explicit.mkdir()
|
||||
monty_tool = MontyExecuteCodeTool(
|
||||
workspace_root=tmp_path,
|
||||
file_mounts=[FileMount(host_path=explicit, mount_path="/input", mode="read-only")],
|
||||
)
|
||||
input_mounts = [m for m in monty_tool._effective_mounts() if m.mount_path == "/input"]
|
||||
assert len(input_mounts) == 1
|
||||
assert input_mounts[0].mode == "read-only"
|
||||
assert input_mounts[0].host_path == explicit.resolve()
|
||||
|
||||
|
||||
def test_remove_file_mount_raises_on_missing() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
with pytest.raises(KeyError):
|
||||
monty_tool.remove_file_mount("/never-added")
|
||||
|
||||
|
||||
def test_dynamic_description_mentions_filesystem_when_mounts_configured(tmp_path: Path) -> None:
|
||||
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
|
||||
description = monty_tool.description
|
||||
assert "Filesystem access is enabled" in description
|
||||
assert "/input" in description
|
||||
|
||||
|
||||
def test_dynamic_description_default_mentions_no_filesystem() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
description = monty_tool.description
|
||||
assert "Filesystem access is unavailable" in description
|
||||
|
||||
|
||||
def test_resource_limits_round_trip() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(resource_limits={"max_duration_secs": 5.0})
|
||||
assert monty_tool.resource_limits == {"max_duration_secs": 5.0}
|
||||
state = monty_tool.build_serializable_state()
|
||||
assert state["resource_limits"] == {"max_duration_secs": 5.0}
|
||||
|
||||
|
||||
def test_build_instructions_includes_registered_tools() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
instructions = monty_tool.build_instructions(tools_visible_to_model=False)
|
||||
assert "add_tool" in instructions
|
||||
assert "execute_code" in instructions
|
||||
assert "asyncio.gather" in instructions
|
||||
|
||||
|
||||
def test_execute_code_filtered_out_when_added_as_tool() -> None:
|
||||
spurious = FunctionTool(
|
||||
name="execute_code",
|
||||
description="should not appear",
|
||||
func=lambda: None,
|
||||
)
|
||||
monty_tool = MontyExecuteCodeTool(tools=[spurious, add_tool])
|
||||
assert [t.name for t in monty_tool.get_tools()] == ["add_tool"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_code behavior with the fake Monty runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_run_code_with_no_tools_returns_default_text() -> None:
|
||||
_set_script(_FakeMontyComplete(output=None))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
result = await monty_tool._run_code(code="None")
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Content)
|
||||
|
||||
|
||||
async def test_run_code_surfaces_stdout_and_output() -> None:
|
||||
_set_script(_PrintAction("hello\n"), _FakeMontyComplete(output=42))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
result = await monty_tool._run_code(code="print('hello')")
|
||||
|
||||
text_contents = [c for c in result if c.type == "text"]
|
||||
assert any("hello" in (c.text or "") for c in text_contents)
|
||||
assert any(
|
||||
(c.text or "").strip() and json.loads(c.text or "null") == 42
|
||||
for c in text_contents
|
||||
if (c.text or "").strip().isdigit()
|
||||
)
|
||||
|
||||
|
||||
async def test_run_code_direct_typed_call_invokes_registered_tool() -> None:
|
||||
func_snapshot = _FakeFunctionSnapshot(
|
||||
function_name="add_tool",
|
||||
call_id=1,
|
||||
kwargs={"a": 2, "b": 3},
|
||||
)
|
||||
future_snapshot = _FakeFutureSnapshot(pending_call_ids=[1])
|
||||
script = _set_script(func_snapshot, future_snapshot, _FakeMontyComplete(output=None))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
await monty_tool._run_code(code="await add_tool(a=2, b=3)")
|
||||
|
||||
payloads = [payload for _, _, payload in script.resume_log]
|
||||
assert {"future": ...} in payloads
|
||||
final_resume = next(p for p in payloads if isinstance(p, dict) and 1 in p)
|
||||
assert final_resume[1] == {"return_value": 5}
|
||||
|
||||
|
||||
async def test_run_code_call_tool_fallback_invokes_registered_tool() -> None:
|
||||
func_snapshot = _FakeFunctionSnapshot(
|
||||
function_name="call_tool",
|
||||
call_id=7,
|
||||
args=("add_tool",),
|
||||
kwargs={"a": 4, "b": 8},
|
||||
)
|
||||
future_snapshot = _FakeFutureSnapshot(pending_call_ids=[7])
|
||||
script = _set_script(func_snapshot, future_snapshot, _FakeMontyComplete(output=None))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
await monty_tool._run_code(code="await call_tool('add_tool', a=4, b=8)")
|
||||
|
||||
payloads = [payload for _, _, payload in script.resume_log]
|
||||
final_resume = next(p for p in payloads if isinstance(p, dict) and 7 in p)
|
||||
assert final_resume[7] == {"return_value": 12}
|
||||
|
||||
|
||||
async def test_run_code_unknown_tool_returns_nameerror_resume() -> None:
|
||||
func_snapshot = _FakeFunctionSnapshot(
|
||||
function_name="does_not_exist",
|
||||
call_id=11,
|
||||
)
|
||||
script = _set_script(func_snapshot, _FakeMontyComplete(output=None))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
await monty_tool._run_code(code="await does_not_exist()")
|
||||
|
||||
payloads = [payload for _, _, payload in script.resume_log]
|
||||
assert any(isinstance(p, dict) and p.get("exc_type") == "NameError" for p in payloads)
|
||||
|
||||
|
||||
async def test_run_code_os_function_is_rejected_with_permissionerror() -> None:
|
||||
os_snapshot = _FakeFunctionSnapshot(
|
||||
function_name="os.listdir",
|
||||
call_id=12,
|
||||
is_os_function=True,
|
||||
)
|
||||
script = _set_script(os_snapshot, _FakeMontyComplete(output=None))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
await monty_tool._run_code(code="import os; os.listdir('.')")
|
||||
|
||||
payloads = [payload for _, _, payload in script.resume_log]
|
||||
assert any(isinstance(p, dict) and p.get("exc_type") == "PermissionError" for p in payloads)
|
||||
|
||||
|
||||
async def test_when_any_returns_nameerror_now_that_it_is_removed() -> None:
|
||||
"""`when_any` is no longer part of the DSL and should resolve to a NameError."""
|
||||
func_snapshot = _FakeFunctionSnapshot(
|
||||
function_name="when_any",
|
||||
call_id=99,
|
||||
args=([{"tool": "add_tool", "kwargs": {"a": 1, "b": 2}}],),
|
||||
)
|
||||
script = _set_script(func_snapshot, _FakeMontyComplete(output=None))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
await monty_tool._run_code(code="await when_any([{'tool': 'add_tool', 'kwargs': {'a': 1, 'b': 2}}])")
|
||||
|
||||
payloads = [payload for _, _, payload in script.resume_log]
|
||||
assert any(isinstance(p, dict) and p.get("exc_type") == "NameError" for p in payloads)
|
||||
|
||||
|
||||
async def test_run_code_call_tool_with_unregistered_name_returns_error() -> None:
|
||||
func_snapshot = _FakeFunctionSnapshot(
|
||||
function_name="call_tool",
|
||||
call_id=20,
|
||||
args=("missing",),
|
||||
kwargs={},
|
||||
)
|
||||
script = _set_script(func_snapshot, _FakeMontyComplete(output=None))
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
|
||||
await monty_tool._run_code(code="await call_tool('missing')")
|
||||
|
||||
payloads = [payload for _, _, payload in script.resume_log]
|
||||
assert any(
|
||||
isinstance(p, dict) and p.get("exc_type") == "ValueError" and "Tool 'missing'" in p.get("message", "")
|
||||
for p in payloads
|
||||
)
|
||||
|
||||
|
||||
async def test_run_code_returns_error_content_on_runtime_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _BoomBridge:
|
||||
def __init__(self, tool_map: Any, **_: Any) -> None:
|
||||
pass
|
||||
|
||||
async def run(self, code: str) -> dict[str, Any]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(execute_code_module, "InlineCodeBridge", _BoomBridge)
|
||||
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
result = await monty_tool._run_code(code="x = 1")
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "error"
|
||||
assert "boom" in (result[0].error_details or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MontyCodeActProvider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_provider_injects_execute_code_tool_and_instructions() -> None:
|
||||
provider = MontyCodeActProvider(tools=[add_tool])
|
||||
context = SessionContext(input_messages=[Message(role="user", contents=[Content.from_text("hi")])])
|
||||
state: dict[str, Any] = {}
|
||||
|
||||
await provider.before_run(agent=MagicMock(), session=None, context=context, state=state)
|
||||
|
||||
assert state["monty_codeact"]["tool_names"] == ["add_tool"]
|
||||
assert any("add_tool" in instruction for instruction in context.instructions)
|
||||
assert len(context.tools) == 1
|
||||
assert isinstance(context.tools[0], MontyExecuteCodeTool)
|
||||
# The injected tool is a per-run snapshot, not the provider's stored copy.
|
||||
assert context.tools[0] is not provider._execute_code_tool # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_provider_delegates_tool_management_to_internal_tool() -> None:
|
||||
provider = MontyCodeActProvider()
|
||||
provider.add_tools([add_tool, mul_tool])
|
||||
assert [t.name for t in provider.get_tools()] == ["add_tool", "mul_tool"]
|
||||
|
||||
provider.remove_tool("add_tool")
|
||||
assert [t.name for t in provider.get_tools()] == ["mul_tool"]
|
||||
|
||||
provider.clear_tools()
|
||||
assert provider.get_tools() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_type_stubs - signature smoke test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_type_stubs_emits_dsl_and_tool_signatures() -> None:
|
||||
def custom(x: int, y: str = "z") -> bool:
|
||||
"""Stub-test tool."""
|
||||
return True
|
||||
|
||||
stubs = bridge_module.generate_type_stubs({"custom": custom})
|
||||
|
||||
assert "async def call_tool(name: str, **kwargs: Any) -> Any:" in stubs
|
||||
assert "async def custom(x: int, y: str = ...) -> bool:" in stubs
|
||||
assert "when_any" not in stubs
|
||||
|
||||
|
||||
def test_generate_type_stubs_preserves_none_and_optional() -> None:
|
||||
|
||||
def nullable_return(x: int) -> None:
|
||||
"""Returns nothing."""
|
||||
return
|
||||
|
||||
def optional_param(x: int | None = None) -> bool: # noqa: UP045 - intentional
|
||||
"""Optional via typing.Optional."""
|
||||
return x is None
|
||||
|
||||
def union_param(x: int | str | None) -> str: # noqa: UP007 - intentional
|
||||
"""Union with None."""
|
||||
return str(x)
|
||||
|
||||
stubs = bridge_module.generate_type_stubs({
|
||||
"nullable_return": nullable_return,
|
||||
"optional_param": optional_param,
|
||||
"union_param": union_param,
|
||||
})
|
||||
|
||||
# ``None`` return must round-trip as None, not Any.
|
||||
assert "async def nullable_return(x: int) -> None:" in stubs
|
||||
# ``Optional[X]`` is ``Union[X, None]`` at runtime; preserve None.
|
||||
assert "async def optional_param(x: int | None = ...) -> bool:" in stubs
|
||||
# Multi-arm union with None.
|
||||
assert "async def union_param(x: int | str | None) -> str:" in stubs
|
||||
|
||||
|
||||
def test_generate_type_stubs_skips_non_identifier_tool_names() -> None:
|
||||
"""Tool names that are not valid Python identifiers must not be splatted into stub source.
|
||||
|
||||
The model can still reach them via ``call_tool("weird-name", ...)`` at
|
||||
runtime; they just don't get type-checked stubs.
|
||||
"""
|
||||
|
||||
def evil(x: int) -> int:
|
||||
return x
|
||||
|
||||
def normal(x: int) -> int:
|
||||
return x
|
||||
|
||||
stubs = bridge_module.generate_type_stubs({
|
||||
# Hyphens are not valid identifier chars.
|
||||
"weird-name": evil,
|
||||
# Newlines in the name would inject arbitrary stub source.
|
||||
"broken\n pass\nasync def injected": evil,
|
||||
# Python keywords are valid identifiers per ``str.isidentifier()`` but
|
||||
# would still produce uncompilable stubs.
|
||||
"async": evil,
|
||||
# Real tool that should still appear.
|
||||
"normal": normal,
|
||||
})
|
||||
|
||||
assert "async def normal(x: int) -> int:" in stubs
|
||||
assert "weird-name" not in stubs
|
||||
assert "injected" not in stubs
|
||||
assert "async def async(" not in stubs
|
||||
|
||||
|
||||
async def test_invoke_tool_awaits_partial_wrapped_async_method() -> None:
|
||||
"""A FunctionTool callback registered via partial(FunctionTool.invoke, ...) must be awaited.
|
||||
|
||||
Regression for PR #5915 review feedback: relying on ``inspect.iscoroutinefunction``
|
||||
to choose between ``await`` and ``asyncio.to_thread`` is fragile for
|
||||
``functools.partial`` wrappers (cpython#98590) and would surface the
|
||||
returned coroutine as a JSON-serialization error instead of the real
|
||||
tool result. The bridge must always ``await`` entries in ``self.tool_map``.
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from agent_framework_monty._monty_bridge import InlineCodeBridge
|
||||
|
||||
@tool
|
||||
def adder(a: Annotated[int, ""], b: Annotated[int, ""]) -> int:
|
||||
"""Add."""
|
||||
return a + b
|
||||
|
||||
# Mirrors what _make_tool_callback returns.
|
||||
cb = partial(adder.invoke, skip_parsing=True)
|
||||
bridge = InlineCodeBridge({"adder": cb})
|
||||
|
||||
cid, payload = await bridge._invoke_tool(7, "adder", {"a": 6, "b": 7})
|
||||
assert cid == 7
|
||||
assert payload == {"return_value": 13}, payload
|
||||
@@ -0,0 +1,601 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for ``agent_framework_monty`` exercising the real Monty runtime.
|
||||
|
||||
These tests import the real ``pydantic-monty`` package and run actual Python
|
||||
code through it via :class:`MontyExecuteCodeTool`. They are marked
|
||||
``@pytest.mark.integration`` and are skipped automatically when
|
||||
``pydantic_monty`` is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, Content, Message, tool
|
||||
from agent_framework._sessions import SessionContext
|
||||
|
||||
from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool
|
||||
|
||||
|
||||
def _monty_integration_skip_reason() -> str | None:
|
||||
if importlib.util.find_spec("pydantic_monty") is None:
|
||||
return "pydantic-monty is not installed."
|
||||
return None
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
_monty_integration_skip_reason() is not None,
|
||||
reason=_monty_integration_skip_reason() or "Monty integration tests are disabled.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@tool
|
||||
def add(
|
||||
a: Annotated[int, "First addend"],
|
||||
b: Annotated[int, "Second addend"],
|
||||
) -> int:
|
||||
"""Return ``a + b``."""
|
||||
return a + b
|
||||
|
||||
|
||||
@tool
|
||||
def multiply(
|
||||
a: Annotated[int, "First factor"],
|
||||
b: Annotated[int, "Second factor"],
|
||||
) -> int:
|
||||
"""Return ``a * b``."""
|
||||
return a * b
|
||||
|
||||
|
||||
@tool
|
||||
async def async_echo(value: Annotated[str, "Value to echo"]) -> str:
|
||||
"""Return ``value`` after a no-op await."""
|
||||
await asyncio.sleep(0)
|
||||
return value
|
||||
|
||||
|
||||
def _async_slow_factory(label: str, delay: float) -> Any:
|
||||
@tool(name=f"slow_{label}")
|
||||
async def slow(value: Annotated[int, "Input"]) -> int:
|
||||
"""Sleep asynchronously, then return value untouched."""
|
||||
await asyncio.sleep(delay)
|
||||
return value
|
||||
|
||||
return slow
|
||||
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def restricted(payload: Annotated[str, "Any text"]) -> str:
|
||||
"""A tool that always requires approval."""
|
||||
return payload
|
||||
|
||||
|
||||
def _text_outputs(contents: list[Content]) -> list[str]:
|
||||
return [c.text or "" for c in contents if c.type == "text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_plain_python_print_round_trips() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
result = await monty_tool._run_code(code="print('hello world')")
|
||||
|
||||
texts = _text_outputs(result)
|
||||
assert any("hello world" in text for text in texts)
|
||||
|
||||
|
||||
async def test_last_expression_value_is_returned() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
result = await monty_tool._run_code(code="5 + 7")
|
||||
|
||||
texts = _text_outputs(result)
|
||||
assert any(text.strip() == "12" for text in texts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_direct_typed_tool_call_invokes_host() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add])
|
||||
result = await monty_tool._run_code(code="print(await add(a=2, b=3))")
|
||||
|
||||
texts = _text_outputs(result)
|
||||
assert any("5" in text for text in texts)
|
||||
|
||||
|
||||
async def test_call_tool_fallback_invokes_host() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add])
|
||||
result = await monty_tool._run_code(code="print(await call_tool('add', a=4, b=8))")
|
||||
|
||||
texts = _text_outputs(result)
|
||||
assert any("12" in text for text in texts)
|
||||
|
||||
|
||||
async def test_async_host_tool_is_awaited() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[async_echo])
|
||||
result = await monty_tool._run_code(code="print(await async_echo(value='ping'))")
|
||||
|
||||
texts = _text_outputs(result)
|
||||
assert any("ping" in text for text in texts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_asyncio_gather_fans_out_tool_calls_concurrently() -> None:
|
||||
"""Two async tools dispatched via ``asyncio.gather`` should run on the event loop in parallel.
|
||||
|
||||
Sync tools cannot fan out (FunctionTool.invoke runs them inline on the event loop),
|
||||
so this test uses async host tools to verify the bridge's gather pipeline does
|
||||
not introduce extra serialization.
|
||||
"""
|
||||
slow_a = _async_slow_factory("a", delay=0.25)
|
||||
slow_b = _async_slow_factory("b", delay=0.25)
|
||||
monty_tool = MontyExecuteCodeTool(tools=[slow_a, slow_b])
|
||||
|
||||
code = """
|
||||
results = await asyncio.gather(slow_a(value=1), slow_b(value=2))
|
||||
print(results)
|
||||
"""
|
||||
|
||||
start = time.perf_counter()
|
||||
result = await monty_tool._run_code(code=code)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
texts = _text_outputs(result)
|
||||
assert any("[1, 2]" in text for text in texts)
|
||||
# Allow some scheduling slack but verify it's noticeably less than sequential (~0.5s).
|
||||
assert elapsed < 0.45, f"Expected concurrent execution; took {elapsed:.3f}s"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox safety + type checking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_type_check_rejects_wrong_argument_type() -> None:
|
||||
invocation_count = {"count": 0}
|
||||
|
||||
@tool
|
||||
def typed_add(
|
||||
a: Annotated[int, "First"],
|
||||
b: Annotated[int, "Second"],
|
||||
) -> int:
|
||||
"""Add two ints; records invocations."""
|
||||
invocation_count["count"] += 1
|
||||
return a + b
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(tools=[typed_add])
|
||||
result = await monty_tool._run_code(code="print(await typed_add(a='not an int', b=3))")
|
||||
|
||||
texts = _text_outputs(result)
|
||||
errors = [c for c in result if c.type == "error"]
|
||||
# Either ty raises and surfaces as an error Content, or Monty reports the typing error in stdout.
|
||||
assert errors or any("type" in text.lower() or "monty" in text.lower() for text in texts)
|
||||
assert invocation_count["count"] == 0
|
||||
|
||||
|
||||
async def test_os_calls_are_blocked() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
code = """
|
||||
try:
|
||||
import os
|
||||
os.listdir('/')
|
||||
print('LEAKED')
|
||||
except PermissionError as exc:
|
||||
print('blocked:', exc)
|
||||
except Exception as exc:
|
||||
print('other:', type(exc).__name__)
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
texts = _text_outputs(result)
|
||||
assert not any("LEAKED" in text for text in texts)
|
||||
assert any("blocked" in text or "PermissionError" in text or "other" in text for text in texts)
|
||||
|
||||
|
||||
async def test_unknown_tool_call_returns_clean_error() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add])
|
||||
code = """
|
||||
try:
|
||||
await call_tool('missing')
|
||||
except Exception as exc:
|
||||
print('err:', type(exc).__name__, str(exc))
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
texts = _text_outputs(result)
|
||||
assert any("missing" in text for text in texts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Print capture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_print_truncation_caps_output() -> None:
|
||||
monty_tool = MontyExecuteCodeTool()
|
||||
# Emit more than MAX_PRINT_OUTPUT_CHARS bytes of output.
|
||||
code = """
|
||||
for _ in range(2000):
|
||||
print('X' * 64)
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
texts = _text_outputs(result)
|
||||
combined = "\n".join(texts)
|
||||
assert len(combined) <= 9000 # MAX_PRINT_OUTPUT_CHARS=8192 plus a small truncation marker
|
||||
assert "[stdout truncated]" in combined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem (workspace_root, file_mounts, output capture, resource limits)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_workspace_root_reads_seed_files_from_host(tmp_path: Any) -> None:
|
||||
seed = tmp_path / "seed.txt"
|
||||
seed.write_text("hello from host", encoding="utf-8")
|
||||
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
|
||||
|
||||
code = """
|
||||
import pathlib
|
||||
data = pathlib.Path('/input/seed.txt').read_text()
|
||||
print(data)
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
texts = _text_outputs(result)
|
||||
assert any("hello from host" in text for text in texts)
|
||||
|
||||
|
||||
async def test_workspace_root_writes_are_captured_as_content(tmp_path: Any) -> None:
|
||||
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
|
||||
|
||||
code = """
|
||||
import pathlib
|
||||
pathlib.Path('/input/report.txt').write_text('result-payload')
|
||||
print('wrote report')
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
data_contents = [c for c in result if c.type == "data"]
|
||||
assert len(data_contents) == 1, [c.type for c in result]
|
||||
written = data_contents[0]
|
||||
# Content.from_data stores bytes as a base64-encoded data: URI.
|
||||
import base64
|
||||
|
||||
assert written.uri is not None
|
||||
payload = written.uri.split(",", 1)[1]
|
||||
assert base64.b64decode(payload) == b"result-payload"
|
||||
assert (written.additional_properties or {}).get("path") == "/input/report.txt"
|
||||
# And the file actually landed on the host filesystem (read-write mode).
|
||||
assert (tmp_path / "report.txt").read_text() == "result-payload"
|
||||
|
||||
|
||||
async def test_read_only_mount_writes_are_rejected_and_not_captured(tmp_path: Any) -> None:
|
||||
from agent_framework_monty import FileMount
|
||||
|
||||
seed = tmp_path / "seed.txt"
|
||||
seed.write_text("ro-content", encoding="utf-8")
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(
|
||||
file_mounts=[FileMount(host_path=tmp_path, mount_path="/ro", mode="read-only")],
|
||||
)
|
||||
|
||||
code = """
|
||||
import pathlib
|
||||
print(pathlib.Path('/ro/seed.txt').read_text())
|
||||
try:
|
||||
pathlib.Path('/ro/should-not-exist.txt').write_text('nope')
|
||||
print('LEAKED')
|
||||
except Exception as exc:
|
||||
print('write blocked:', type(exc).__name__)
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
texts = _text_outputs(result)
|
||||
assert any("ro-content" in t for t in texts)
|
||||
assert not any("LEAKED" in t for t in texts)
|
||||
# No write went to host; no captured Content for the rejected write.
|
||||
assert not (tmp_path / "should-not-exist.txt").exists()
|
||||
assert not any(c.type == "data" for c in result)
|
||||
|
||||
|
||||
async def test_overlay_mount_writes_do_not_persist_to_host(tmp_path: Any) -> None:
|
||||
from agent_framework_monty import FileMount
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(
|
||||
file_mounts=[FileMount(host_path=tmp_path, mount_path="/overlay", mode="overlay")],
|
||||
)
|
||||
|
||||
code = """
|
||||
import pathlib
|
||||
pathlib.Path('/overlay/scratch.txt').write_text('overlay-only')
|
||||
print('wrote')
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
assert any("wrote" in t for t in _text_outputs(result))
|
||||
# Overlay writes stay in-memory: nothing on host, nothing captured.
|
||||
assert not (tmp_path / "scratch.txt").exists()
|
||||
assert not any(c.type == "data" for c in result)
|
||||
|
||||
|
||||
async def test_resource_limit_short_duration_aborts_long_loop() -> None:
|
||||
# Cap CPU time hard; a busy loop should be killed before it can print 'done'.
|
||||
monty_tool = MontyExecuteCodeTool(resource_limits={"max_duration_secs": 0.2})
|
||||
|
||||
code = """
|
||||
total = 0
|
||||
for i in range(10_000_000):
|
||||
total += i
|
||||
print('done', total)
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
# Result is either an error Content (timeout surfaces as RuntimeError) or
|
||||
# truncated stdout without the 'done' marker.
|
||||
texts = _text_outputs(result)
|
||||
assert not any("done" in t for t in texts), texts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Symlink escape regression (MSRC-style)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _symlinks_supported(tmp: Any) -> bool:
|
||||
"""Return True if the current platform/environment supports symlinks.
|
||||
|
||||
Mirrors python/packages/core/tests/core/test_skills.py so the symlink
|
||||
regression tests are skipped on restricted Windows CI runners instead of
|
||||
failing on ``OSError`` / ``NotImplementedError`` during creation.
|
||||
"""
|
||||
test_target = tmp / "_symlink_test_target"
|
||||
test_link = tmp / "_symlink_test_link"
|
||||
try:
|
||||
test_target.write_text("test", encoding="utf-8")
|
||||
test_link.symlink_to(test_target)
|
||||
return True
|
||||
except (OSError, NotImplementedError):
|
||||
return False
|
||||
finally:
|
||||
test_link.unlink(missing_ok=True)
|
||||
test_target.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def test_symlinks_inside_workspace_are_not_followed_by_runtime(tmp_path: Any) -> None:
|
||||
"""A pre-existing symlink in workspace_root must NOT let sandbox code read its target.
|
||||
|
||||
Monty's mount layer enforces this (PermissionError at the OS bridge), but we
|
||||
pin the behavior here so any future change to the OS dispatch path is
|
||||
detected.
|
||||
"""
|
||||
if not _symlinks_supported(tmp_path):
|
||||
pytest.skip("Symlinks not supported on this platform/environment")
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside_secret.txt"
|
||||
outside.write_text("SECRET_OUTSIDE_WORKSPACE", encoding="utf-8")
|
||||
(workspace / "leak.txt").symlink_to(outside)
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(workspace_root=workspace)
|
||||
code = """
|
||||
import pathlib
|
||||
try:
|
||||
print('read:', pathlib.Path('/input/leak.txt').read_text())
|
||||
except PermissionError as exc:
|
||||
print('blocked:', exc)
|
||||
except Exception as exc:
|
||||
print('other:', type(exc).__name__, exc)
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
texts = _text_outputs(result)
|
||||
assert not any("SECRET_OUTSIDE_WORKSPACE" in t for t in texts), texts
|
||||
assert any("blocked" in t or "PermissionError" in t or "other" in t for t in texts), texts
|
||||
|
||||
|
||||
async def test_post_capture_skips_symlinks_pointing_outside_workspace(tmp_path: Any) -> None:
|
||||
"""File capture must NOT read through a symlink that points outside the mount.
|
||||
|
||||
Reproduces the MSRC-reported Hyperlight pattern in Monty's post-execution
|
||||
file-capture path: an attacker-placed ``workspace/leak.txt -> /outside/secret``
|
||||
must not be returned as Content.
|
||||
"""
|
||||
if not _symlinks_supported(tmp_path):
|
||||
pytest.skip("Symlinks not supported on this platform/environment")
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside_secret.txt"
|
||||
outside.write_text("SECRET_OUTSIDE_WORKSPACE", encoding="utf-8")
|
||||
(workspace / "leak.txt").symlink_to(outside)
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "deep.txt").write_text("DEEP_SECRET", encoding="utf-8")
|
||||
(workspace / "leak_dir").symlink_to(outside_dir)
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(workspace_root=workspace)
|
||||
# Run trivial code so the post-execution scan fires.
|
||||
result = await monty_tool._run_code(code="print('ran')")
|
||||
|
||||
# Inspect the URIs of any returned data Content items.
|
||||
import base64
|
||||
|
||||
leaked_paths: list[str] = []
|
||||
leaked_bodies: list[bytes] = []
|
||||
for content in result:
|
||||
if content.type != "data" or not content.uri:
|
||||
continue
|
||||
payload = content.uri.split(",", 1)[1] if "," in content.uri else ""
|
||||
try:
|
||||
body = base64.b64decode(payload)
|
||||
except Exception: # noqa: BLE001
|
||||
body = b""
|
||||
leaked_bodies.append(body)
|
||||
leaked_paths.append((content.additional_properties or {}).get("path", ""))
|
||||
|
||||
assert not any(b"SECRET_OUTSIDE_WORKSPACE" in body for body in leaked_bodies), (
|
||||
"Symlink file outside workspace was captured: " + repr(leaked_paths)
|
||||
)
|
||||
assert not any(b"DEEP_SECRET" in body for body in leaked_bodies), (
|
||||
"Symlinked directory escape was captured: " + repr(leaked_paths)
|
||||
)
|
||||
|
||||
|
||||
async def test_post_capture_still_returns_real_writes_when_symlinks_present(tmp_path: Any) -> None:
|
||||
"""The symlink-skipping logic must not regress capture of legitimate sandbox writes."""
|
||||
if not _symlinks_supported(tmp_path):
|
||||
pytest.skip("Symlinks not supported on this platform/environment")
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside_secret.txt"
|
||||
outside.write_text("SHOULD_NEVER_LEAK", encoding="utf-8")
|
||||
(workspace / "leak.txt").symlink_to(outside)
|
||||
|
||||
monty_tool = MontyExecuteCodeTool(workspace_root=workspace)
|
||||
code = """
|
||||
import pathlib
|
||||
pathlib.Path('/input/report.txt').write_text('legit-output')
|
||||
print('wrote')
|
||||
"""
|
||||
result = await monty_tool._run_code(code=code)
|
||||
import base64
|
||||
|
||||
data_items = [c for c in result if c.type == "data" and c.uri]
|
||||
# Exactly one new file should be captured: report.txt.
|
||||
assert len(data_items) == 1, [(c.additional_properties or {}).get("path") for c in data_items]
|
||||
item = data_items[0]
|
||||
assert (item.additional_properties or {}).get("path") == "/input/report.txt"
|
||||
payload = item.uri.split(",", 1)[1] if item.uri and "," in item.uri else ""
|
||||
assert base64.b64decode(payload) == b"legit-output"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider + approval gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_provider_run_tool_executes_real_monty_end_to_end() -> None:
|
||||
provider = MontyCodeActProvider(tools=[add])
|
||||
context = SessionContext(input_messages=[Message(role="user", contents=[Content.from_text("hi")])])
|
||||
state: dict[str, Any] = {}
|
||||
|
||||
await provider.before_run(agent=MagicMock(), session=None, context=context, state=state)
|
||||
|
||||
run_tool = context.tools[0]
|
||||
assert isinstance(run_tool, MontyExecuteCodeTool)
|
||||
|
||||
result = await run_tool._run_code(code="print(await add(a=10, b=32))")
|
||||
texts = _text_outputs(result)
|
||||
assert any("42" in text for text in texts)
|
||||
|
||||
|
||||
async def test_approval_required_tool_gates_execute_code_end_to_end() -> None:
|
||||
provider = MontyCodeActProvider(tools=[restricted])
|
||||
context = SessionContext(input_messages=[Message(role="user", contents=[Content.from_text("hi")])])
|
||||
state: dict[str, Any] = {}
|
||||
|
||||
await provider.before_run(agent=MagicMock(), session=None, context=context, state=state)
|
||||
run_tool = context.tools[0]
|
||||
assert isinstance(run_tool, MontyExecuteCodeTool)
|
||||
assert run_tool.approval_mode == "always_require"
|
||||
assert state["monty_codeact"]["approval_mode"] == "always_require"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end Agent run with a fake chat client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_agent_runs_monty_codeact_end_to_end() -> None:
|
||||
"""A fake chat client emits one execute_code tool call; Monty runs it end-to-end."""
|
||||
from collections.abc import Awaitable, Mapping, MutableSequence
|
||||
|
||||
from agent_framework import (
|
||||
BaseChatClient,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
FunctionInvocationLayer,
|
||||
ResponseStream,
|
||||
)
|
||||
|
||||
class _FakeCodeActChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
self.call_count = 0
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: MutableSequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
if stream:
|
||||
raise AssertionError("Streaming is not used in this integration test.")
|
||||
|
||||
async def _get_response() -> ChatResponse:
|
||||
self.call_count += 1
|
||||
|
||||
if self.call_count == 1:
|
||||
return ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id="execute_code_call",
|
||||
name="execute_code",
|
||||
arguments={"code": "print(await add(a=6, b=7))"},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
function_results = [
|
||||
content for message in messages for content in message.contents if content.type == "function_result"
|
||||
]
|
||||
assert len(function_results) == 1
|
||||
|
||||
result_content = function_results[0]
|
||||
result_text = ""
|
||||
if isinstance(result_content.result, list):
|
||||
for item in result_content.result:
|
||||
text = getattr(item, "text", None)
|
||||
if text:
|
||||
result_text += text
|
||||
else:
|
||||
result_text = str(result_content.result or "")
|
||||
|
||||
return ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[f"answer: {result_text.strip() or 'none'}"],
|
||||
)
|
||||
)
|
||||
|
||||
return _get_response()
|
||||
|
||||
client = _FakeCodeActChatClient()
|
||||
provider = MontyCodeActProvider(tools=[add])
|
||||
agent = Agent(client=client, context_providers=[provider])
|
||||
|
||||
response = await agent.run("Add 6 and 7 inside execute_code.")
|
||||
assert "13" in (response.text or "")
|
||||
assert client.call_count == 2
|
||||
@@ -88,6 +88,7 @@ agent-framework-github-copilot = { workspace = true }
|
||||
agent-framework-hyperlight = { workspace = true }
|
||||
agent-framework-lab = { workspace = true }
|
||||
agent-framework-mem0 = { workspace = true }
|
||||
agent-framework-monty = { workspace = true }
|
||||
agent-framework-ollama = { workspace = true }
|
||||
agent-framework-openai = { workspace = true }
|
||||
agent-framework-orchestrations = { workspace = true }
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
# Hyperlight CodeAct context provider
|
||||
# CodeAct context providers
|
||||
|
||||
Demonstrates the provider-owned [Hyperlight](https://github.com/hyperlight-dev/hyperlight)
|
||||
CodeAct flow. `HyperlightCodeActProvider` injects an `execute_code` tool into the
|
||||
agent and keeps the registered sandbox tools (`compute`, `fetch_data`) hidden
|
||||
from the model — the model must call them from inside the sandbox using
|
||||
`call_tool(...)`.
|
||||
Demonstrates the provider-owned CodeAct flow with two backends:
|
||||
|
||||
| File | Backend | Notes |
|
||||
|------|---------|-------|
|
||||
| [`code_act.py`](code_act.py) | [Hyperlight](https://github.com/hyperlight-dev/hyperlight) WASM sandbox via `HyperlightCodeActProvider` | Hardened sandbox with WASM isolation; sandbox tools called via `call_tool(...)`. |
|
||||
| [`monty_code_act.py`](monty_code_act.py) | [Monty](https://github.com/pydantic/monty) Rust-based Python interpreter via `MontyCodeActProvider` (alpha) | Cross-platform pure interpreter; sandbox tools can be called as typed async functions (`await compute(...)`) or via `call_tool(...)`. |
|
||||
|
||||
Both providers inject an `execute_code` tool into the agent and keep the
|
||||
registered sandbox tools (`compute`, `fetch_data`) hidden from the model — the
|
||||
model invokes them from inside the sandbox.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install agent-framework agent-framework-hyperlight --pre
|
||||
pip install agent-framework agent-framework-hyperlight --pre # Hyperlight sample
|
||||
pip install agent-framework agent-framework-monty --pre # Monty sample
|
||||
```
|
||||
|
||||
> The Hyperlight Wasm backend is currently published only for `linux/x86_64` and
|
||||
> `win32/AMD64` with Python `<3.14`. On other platforms `execute_code` will fail
|
||||
> at runtime when it tries to create the sandbox.
|
||||
>
|
||||
> Monty is cross-platform and has no hypervisor/WASM backend dependency, but it
|
||||
> interprets a Python subset (e.g. `os`/network/subprocess access is blocked).
|
||||
> `agent-framework-monty` is an alpha package and is not yet part of
|
||||
> `agent-framework[all]`; install it explicitly with `--pre`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -25,7 +36,8 @@ pip install agent-framework agent-framework-hyperlight --pre
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python code_act.py
|
||||
python code_act.py # Hyperlight
|
||||
python monty_code_act.py # Monty
|
||||
```
|
||||
|
||||
See [`code_act.py`](code_act.py) for the full annotated example.
|
||||
See the source files for the full annotated examples.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_monty import MontyCodeActProvider
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
"""This sample demonstrates the provider-owned Monty CodeAct flow.
|
||||
|
||||
The sample keeps `compute` and `fetch_data` off the direct agent tool surface and
|
||||
registers them only with `MontyCodeActProvider`. The model therefore sees a
|
||||
single `execute_code` tool and calls the provider-owned tools from inside the
|
||||
sandbox - either as typed async functions (`await compute(...)`) or via the
|
||||
generic `call_tool(...)` fallback.
|
||||
|
||||
`MontyCodeActProvider` uses [pydantic-monty](https://github.com/pydantic/monty),
|
||||
a Rust-based Python interpreter, so it runs cross-platform with no
|
||||
hypervisor/WASM backend dependency.
|
||||
|
||||
Note: `agent-framework-monty` is an alpha package and is not yet part of
|
||||
`agent-framework[all]`. Install it explicitly with:
|
||||
|
||||
pip install agent-framework agent-framework-monty --pre
|
||||
|
||||
It is imported as `agent_framework_monty` (no lazy-loading namespace yet).
|
||||
"""
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_CYAN = "\033[36m"
|
||||
_YELLOW = "\033[33m"
|
||||
_GREEN = "\033[32m"
|
||||
_DIM = "\033[2m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
class _ColoredFormatter(logging.Formatter):
|
||||
"""Dim logger output so it does not compete with sample prints."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return f"{_DIM}{super().format(record)}{_RESET}"
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logging.getLogger().handlers[0].setFormatter(
|
||||
_ColoredFormatter("[%(asctime)s] %(levelname)s: %(message)s"),
|
||||
)
|
||||
|
||||
|
||||
@function_middleware
|
||||
async def log_function_calls(
|
||||
context: FunctionInvocationContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Log tool calls, including readable execute_code blocks."""
|
||||
import time
|
||||
|
||||
function_name = context.function.name
|
||||
arguments = context.arguments if isinstance(context.arguments, dict) else {}
|
||||
|
||||
if function_name == "execute_code" and "code" in arguments:
|
||||
print(f"\n{_YELLOW}{'─' * 60}")
|
||||
print("â–¶ execute_code")
|
||||
print(f"{'─' * 60}{_RESET}")
|
||||
print(arguments["code"])
|
||||
print(f"{_YELLOW}{'─' * 60}{_RESET}")
|
||||
else:
|
||||
pairs = ", ".join(f"{name}={value!r}" for name, value in arguments.items())
|
||||
print(f"\n{_YELLOW}â–¶ {function_name}({pairs}){_RESET}")
|
||||
|
||||
start = time.perf_counter()
|
||||
await call_next()
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
result = context.result
|
||||
if function_name == "execute_code" and isinstance(result, list):
|
||||
for output in result:
|
||||
if output.type == "text" and output.text:
|
||||
print(f"{_GREEN}stdout:\n{output.text}{_RESET}")
|
||||
elif output.type == "error" and output.error_details:
|
||||
print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}")
|
||||
else:
|
||||
print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}")
|
||||
|
||||
print(f"{_DIM} ({elapsed:.4f}s){_RESET}")
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def compute(
|
||||
operation: Annotated[
|
||||
Literal["add", "subtract", "multiply", "divide"],
|
||||
"Math operation: add, subtract, multiply, or divide.",
|
||||
],
|
||||
a: Annotated[float, "First numeric operand."],
|
||||
b: Annotated[float, "Second numeric operand."],
|
||||
) -> float:
|
||||
"""Perform a math operation for sandboxed code."""
|
||||
operations = {
|
||||
"add": a + b,
|
||||
"subtract": a - b,
|
||||
"multiply": a * b,
|
||||
"divide": a / b if b else float("inf"),
|
||||
}
|
||||
return operations[operation]
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
async def fetch_data(
|
||||
table: Annotated[str, "Name of the simulated table to query."],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch records from a named table."""
|
||||
await asyncio.sleep(0.5)
|
||||
data: dict[str, list[dict[str, Any]]] = {
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice", "role": "admin"},
|
||||
{"id": 2, "name": "Bob", "role": "user"},
|
||||
{"id": 3, "name": "Charlie", "role": "admin"},
|
||||
],
|
||||
"products": [
|
||||
{"id": 101, "name": "Widget", "price": 9.99},
|
||||
{"id": 102, "name": "Gadget", "price": 19.99},
|
||||
],
|
||||
}
|
||||
return data.get(table, [])
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the provider-owned Monty CodeAct sample."""
|
||||
# 1. Create the Monty-backed provider and register sandbox tools on it.
|
||||
codeact = MontyCodeActProvider(
|
||||
tools=[compute, fetch_data],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# 2. Create the client and the agent.
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name="MontyCodeActProviderAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
context_providers=[codeact],
|
||||
middleware=[log_function_calls],
|
||||
)
|
||||
|
||||
# 3. Run a request that should use execute_code plus provider-owned tools.
|
||||
query = (
|
||||
"Fetch all users, find admins, multiply 7*(3*2), and print the users, "
|
||||
"admins, and multiplication result. Use a single execute_code call. "
|
||||
"You may call the registered tools directly as typed async functions "
|
||||
"(`await compute(operation='multiply', a=7, b=6)`) or via "
|
||||
"`call_tool('compute', ...)`."
|
||||
)
|
||||
print(f"{_CYAN}{'=' * 60}")
|
||||
print("Monty CodeAct provider sample")
|
||||
print(f"{'=' * 60}{_RESET}")
|
||||
print(f"{_CYAN}User: {query}{_RESET}")
|
||||
result = await agent.run(query)
|
||||
print(f"{_CYAN}Agent: {result.text}{_RESET}")
|
||||
|
||||
|
||||
"""
|
||||
Sample output (shape only):
|
||||
|
||||
============================================================
|
||||
Monty CodeAct provider sample
|
||||
============================================================
|
||||
User: Fetch all users, find admins, multiply 7*(3*2), ...
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
â–¶ execute_code
|
||||
────────────────────────────────────────────────────────────
|
||||
users = await fetch_data(table="users")
|
||||
admins = [u for u in users if u["role"] == "admin"]
|
||||
result = await compute(operation="multiply", a=7, b=6)
|
||||
print("Users:", users)
|
||||
print("Admins:", admins)
|
||||
print("7 * 6 =", result)
|
||||
────────────────────────────────────────────────────────────
|
||||
stdout:
|
||||
Users: [...]
|
||||
Admins: [...]
|
||||
7 * 6 = 42.0
|
||||
(0.5xxx s)
|
||||
Agent: ...
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -27,6 +27,9 @@ OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
|
||||
# Agent Framework specific settings
|
||||
# ==================================
|
||||
|
||||
# Observability is enabled by default. Set to "false" to opt out.
|
||||
# ENABLE_INSTRUMENTATION=false
|
||||
|
||||
# Enable sensitive data logging (prompts, responses, etc.)
|
||||
# WARNING: Only enable in dev/test environments
|
||||
ENABLE_SENSITIVE_DATA=true
|
||||
@@ -34,9 +37,6 @@ ENABLE_SENSITIVE_DATA=true
|
||||
# Optional: Enable console exporters for debugging
|
||||
# ENABLE_CONSOLE_EXPORTERS=true
|
||||
|
||||
# Optional: Enable observability (automatically enabled if env vars are set or configure_otel_providers() is called)
|
||||
# ENABLE_INSTRUMENTATION=true
|
||||
|
||||
# OpenAI specific variables
|
||||
# ==========================
|
||||
OPENAI_API_KEY="..."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Agent Framework Observability
|
||||
|
||||
This sample folder shows how a Python application can be configured to send Agent Framework observability data to the Application Performance Management (APM) vendor(s) of your choice based on the OpenTelemetry standard.
|
||||
These samples show how to send Agent Framework observability data to the Application Performance Management (APM) backend of your choice, based on the OpenTelemetry standard.
|
||||
|
||||
In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash) and the console.
|
||||
The samples target [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash), and the console, but any OTLP-compatible backend works.
|
||||
|
||||
> **Quick Start**: For local development without Azure setup, you can use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) which runs locally via Docker and provides an excellent telemetry viewing experience for OpenTelemetry data. Or you can use the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio).
|
||||
> **Quick Start**: For local development without Azure setup, use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) (runs locally via Docker), or the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio).
|
||||
|
||||
> Note that it is also possible to use other Application Performance Management (APM) vendors. An example is [Prometheus](https://prometheus.io/docs/introduction/overview/). Please refer to this [page](https://opentelemetry.io/docs/languages/python/exporters/) to learn more about exporters.
|
||||
> Other backends such as [Prometheus](https://prometheus.io/docs/introduction/overview/) are also supported. See the [OpenTelemetry Python exporters](https://opentelemetry.io/docs/languages/python/exporters/) page for the full list.
|
||||
|
||||
For more information, please refer to the following resources:
|
||||
|
||||
@@ -18,19 +18,15 @@ For more information, please refer to the following resources:
|
||||
|
||||
## What to expect
|
||||
|
||||
The Agent Framework Python SDK is designed to efficiently generate comprehensive logs, traces, and metrics throughout the flow of agent/model invocation and tool execution. This allows you to effectively monitor your AI application's performance and accurately track token consumption. It does so based on the Semantic Conventions for GenAI defined by OpenTelemetry, and the workflows emit their own spans to provide end-to-end visibility.
|
||||
The Agent Framework Python SDK is **natively instrumented** to emit logs, traces, and metrics throughout agent/model invocation and tool execution, so you can monitor your AI application's performance and track token consumption. Instrumentation follows the OpenTelemetry [Semantic Conventions for GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/), and workflows emit their own spans for end-to-end visibility.
|
||||
|
||||
Next to what happens in the code when you run, we also make setting up observability as easy as possible. By calling a single function `configure_otel_providers()` from the `agent_framework.observability` module, you can enable telemetry for traces, logs, and metrics. The function automatically reads standard OpenTelemetry environment variables to configure exporters and providers, making it simple to get started.
|
||||
|
||||
### MCP trace propagation
|
||||
|
||||
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries, compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
|
||||
|
||||
**Scope:** automatic `_meta` injection applies only to MCP sessions that the agent process itself opens — `MCPStreamableHTTPTool`, `MCPStdioTool`, and `MCPWebsocketTool` (or any other client-opened `MCPTool` subclass). It does **not** apply to hosted/provider-managed MCP tool configurations such as `FoundryChatClient.get_mcp_tool(...)`, `OpenAIChatClient.get_mcp_tool(...)`, `AnthropicClient.get_mcp_tool(...)`, `GeminiChatClient.get_mcp_tool(...)`, or toolbox-fetched tools (for example, `toolbox = await client.get_toolbox(...)`, then passing `toolbox.tools` into `Agent(tools=...)`), because in those cases the `tools/call` message is issued by the provider service runtime rather than by the agent process. As a result, the framework has no opportunity to inject trace context into those requests, and propagating `traceparent`/`tracestate` across that hosted-service boundary is the responsibility of the service runtime, not Agent Framework. If end-to-end distributed tracing to the downstream MCP server is required, use a client-opened MCP transport instead of a hosted connector.
|
||||
Setting up observability is also easy: a single call to `configure_otel_providers()` from the `agent_framework.observability` module wires up the trace, log, and metric providers. It reads the standard OpenTelemetry environment variables to configure exporters automatically.
|
||||
|
||||
### Five patterns for configuring observability
|
||||
|
||||
We've identified multiple ways to configure observability in your application, depending on your needs:
|
||||
> Setting up observability has two parts: (1) **instrumentation**, the code that generates telemetry, and (2) **exporter/provider configuration**, which decides where that telemetry is sent. Agent Framework is natively instrumented and **enabled by default**, so you only need to handle the second part.
|
||||
|
||||
There are five common ways to do that, depending on your needs:
|
||||
|
||||
**1. Standard otel environment variables, configured for you**
|
||||
|
||||
@@ -42,22 +38,29 @@ from agent_framework.observability import configure_otel_providers
|
||||
# Reads OTEL_EXPORTER_OTLP_* environment variables automatically
|
||||
configure_otel_providers()
|
||||
```
|
||||
|
||||
Or if you just want console exporters:
|
||||
|
||||
```python
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
# Enable console exporters via environment variable
|
||||
|
||||
configure_otel_providers(enable_console_exporters=True)
|
||||
# It is also possible to set ENABLE_CONSOLE_EXPORTERS=true in environment
|
||||
# variables instead of calling `configure_otel_providers()` with the parameter.
|
||||
# The framework will automatically read that and set up console exporters.
|
||||
```
|
||||
|
||||
This is the **recommended approach** for getting started.
|
||||
|
||||
**2. Custom Exporters**
|
||||
One level more control over the exporters that are created is to do that yourself, and then pass them to `configure_otel_providers()`. We will still create the providers for you, but you can customize the exporters as needed:
|
||||
|
||||
For more control, construct exporters yourself and pass them to `configure_otel_providers()`. The framework still creates the providers for you:
|
||||
|
||||
```python
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.exporter import Compression
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
|
||||
# Create custom exporters with specific configuration
|
||||
@@ -67,17 +70,17 @@ exporters = [
|
||||
OTLPMetricExporter(endpoint="http://localhost:4317"),
|
||||
]
|
||||
|
||||
# These will be added alongside any exporters from environment variables
|
||||
configure_otel_providers(exporters=exporters, enable_sensitive_data=True)
|
||||
# These are added alongside any exporters configured from environment variables
|
||||
configure_otel_providers(exporters=exporters)
|
||||
```
|
||||
|
||||
**3. Third party setup**
|
||||
**3. Third-party setup**
|
||||
|
||||
A lot of third party specific otel package, have their own easy setup methods, for example Azure Monitor has `configure_azure_monitor()`. You can use those methods to setup the third party first, and then call `enable_instrumentation()` from the `agent_framework.observability` module to activate the Agent Framework telemetry code paths. In all these cases, if you already setup observability via environment variables, you don't need to call `enable_instrumentation()` as it will be enabled automatically.
|
||||
Many third-party OTel packages ship their own setup helpers (for example, Azure Monitor's `configure_azure_monitor()`). You can use those directly — Agent Framework instrumentation is on by default, so no extra wiring is needed. To also capture sensitive data, call `enable_sensitive_telemetry()` from `agent_framework.observability`.
|
||||
|
||||
```python
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
from agent_framework.observability import create_resource, enable_instrumentation
|
||||
from agent_framework.observability import create_resource, enable_sensitive_telemetry
|
||||
|
||||
# Configure Azure Monitor first
|
||||
configure_azure_monitor(
|
||||
@@ -86,10 +89,10 @@ configure_azure_monitor(
|
||||
enable_live_metrics=True,
|
||||
)
|
||||
|
||||
# Then activate Agent Framework's telemetry code paths
|
||||
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
|
||||
enable_instrumentation(enable_sensitive_data=False)
|
||||
# Optional: opt in to capturing sensitive data
|
||||
enable_sensitive_telemetry()
|
||||
```
|
||||
|
||||
For Microsoft Foundry projects, use `client.configure_azure_monitor()` which retrieves the connection string from the project and configures everything:
|
||||
|
||||
```python
|
||||
@@ -110,7 +113,7 @@ Or with [Langfuse](https://langfuse.com/integrations/frameworks/microsoft-agent-
|
||||
|
||||
```python
|
||||
# environment should be setup correctly, with langfuse urls and keys
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
from langfuse import get_client
|
||||
|
||||
langfuse = get_client()
|
||||
@@ -121,9 +124,9 @@ if langfuse.auth_check():
|
||||
else:
|
||||
print("Authentication failed. Please check your credentials and host.")
|
||||
|
||||
# Then activate Agent Framework's telemetry code paths
|
||||
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
|
||||
enable_instrumentation(enable_sensitive_data=False)
|
||||
# Agent Framework instrumentation is on by default.
|
||||
# Optional: opt in to capturing sensitive data
|
||||
enable_sensitive_telemetry()
|
||||
```
|
||||
|
||||
Or with [Comet Opik](https://www.comet.com/docs/opik/integrations/microsoft-agent-framework):
|
||||
@@ -131,53 +134,152 @@ Or with [Comet Opik](https://www.comet.com/docs/opik/integrations/microsoft-agen
|
||||
```python
|
||||
import os
|
||||
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
|
||||
# Use Opik OTLP settings from your project settings
|
||||
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "<opik_otlp_endpoint>"
|
||||
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "<opik_otlp_headers>"
|
||||
|
||||
# Then activate Agent Framework's telemetry code paths
|
||||
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
|
||||
enable_instrumentation(enable_sensitive_data=False)
|
||||
# Agent Framework instrumentation is on by default.
|
||||
# Optional: opt in to capturing sensitive data
|
||||
enable_sensitive_telemetry()
|
||||
```
|
||||
|
||||
**4. Manual setup**
|
||||
Of course you can also do a complete manual setup of exporters, providers, and instrumentation. Please refer to sample [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a comprehensive example of how to manually setup exporters and providers for traces, logs, and metrics that will get sent to the console. This gives you full control over which exporters and providers to use. We do have a helper function `create_resource()` in the `agent_framework.observability` module that you can use to create a resource with the appropriate service name and version based on environment variables or standard defaults for Agent Framework, this is not used in the sample.
|
||||
|
||||
**5. Auto-instrumentation (zero-code)**
|
||||
You can also use the [OpenTelemetry CLI tool](https://opentelemetry.io/docs/instrumentation/python/getting-started/#automatic-instrumentation) to automatically instrument your application without changing any code. Please refer to sample [advanced_zero_code.py](./advanced_zero_code.py) for an example of how to use the CLI tool to enable instrumentation for Agent Framework applications.
|
||||
For full control, set up providers and exporters yourself. See [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a complete example that sends traces, logs, and metrics to the console. The `create_resource()` helper in `agent_framework.observability` can build a resource with the appropriate service name and version from environment variables (or sensible defaults), although the sample does not use it.
|
||||
|
||||
**5. Zero-code provider/exporter configuration**
|
||||
|
||||
Because Agent Framework is **natively instrumented** with OpenTelemetry, you do not need to auto-instrument the framework itself. You can, however, use the [`opentelemetry-instrument`](https://opentelemetry.io/docs/zero-code/python/) CLI wrapper to configure the global tracer/meter providers and exporters from environment variables (or CLI flags) at process startup. Your application code then does not need to call `configure_otel_providers()` — the native spans and metrics from Agent Framework are picked up by the globally configured pipeline. See [advanced_zero_code.py](./advanced_zero_code.py) for an example.
|
||||
|
||||
### MCP trace propagation
|
||||
|
||||
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally configured OpenTelemetry propagator(s) — W3C Trace Context by default (producing `traceparent` and `tracestate`) — so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries, compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
|
||||
|
||||
**Scope:** automatic `_meta` injection applies only to MCP sessions that the agent process itself opens — `MCPStreamableHTTPTool`, `MCPStdioTool`, and `MCPWebsocketTool` (or any other client-opened `MCPTool` subclass). It does **not** apply to hosted or provider-managed MCP tool configurations such as `FoundryChatClient.get_mcp_tool(...)`, `OpenAIChatClient.get_mcp_tool(...)`, `AnthropicClient.get_mcp_tool(...)`, `GeminiChatClient.get_mcp_tool(...)`, or toolbox-fetched tools (e.g. `toolbox = await client.get_toolbox(...)` then `Agent(tools=toolbox.tools)`). In those cases the `tools/call` message is issued by the provider service runtime rather than by the agent process, so propagating `traceparent`/`tracestate` across that boundary is the service runtime's responsibility. If you need end-to-end distributed tracing to the downstream MCP server, use a client-opened MCP transport instead of a hosted connector.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Dependencies
|
||||
|
||||
As part of Agent Framework we use the following OpenTelemetry packages:
|
||||
- `opentelemetry-api`
|
||||
- `opentelemetry-sdk`
|
||||
- `opentelemetry-semantic-conventions-ai`
|
||||
Agent Framework's core depends on **`opentelemetry-api`** only — the API package is enough for the instrumentation hooks (spans, meters, log records) to emit telemetry, and it has no runtime side effects when no provider is configured.
|
||||
|
||||
We do not install exporters by default, so you will need to add those yourself, this prevents us from installing unnecessary dependencies. For Application Insights, you will need to install `azure-monitor-opentelemetry`. For Aspire Dashboard or other OTLP compatible backends, you will need to install `opentelemetry-exporter-otlp-proto-grpc`. For HTTP protocol support, you will also need to install `opentelemetry-exporter-otlp-proto-http`.
|
||||
If you want the framework to set up providers / exporters for you via `configure_otel_providers()` (or to use the `create_resource()` / `create_metric_views()` helpers), you also need the OpenTelemetry SDK:
|
||||
|
||||
And for many others, different packages are used, so refer to the documentation of the specific exporter you want to use.
|
||||
```bash
|
||||
pip install opentelemetry-sdk
|
||||
```
|
||||
|
||||
If `opentelemetry-sdk` is missing, those helper functions raise a clear `ImportError` telling you to install it. Day-to-day instrumentation still works without the SDK as long as some other component (e.g. `azure-monitor-opentelemetry`, your application bootstrap, an APM agent) has configured the global OpenTelemetry providers.
|
||||
|
||||
Exporters are **not** installed by default — install only what you need:
|
||||
- **Application Insights**: `azure-monitor-opentelemetry`
|
||||
- **Aspire Dashboard or other OTLP/gRPC backends**: `opentelemetry-exporter-otlp-proto-grpc`
|
||||
- **OTLP over HTTP**: `opentelemetry-exporter-otlp-proto-http`
|
||||
|
||||
For other backends, refer to the documentation of the specific exporter.
|
||||
|
||||
### Environment variables
|
||||
|
||||
The following environment variables are used to turn on/off observability of the Agent Framework:
|
||||
Agent Framework reads the following environment variables:
|
||||
|
||||
- `ENABLE_INSTRUMENTATION`
|
||||
- `ENABLE_SENSITIVE_DATA`
|
||||
- `ENABLE_CONSOLE_EXPORTERS`
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `ENABLE_INSTRUMENTATION` | `true` | Set to `false` to disable native instrumentation. See [Disabling instrumentation](#disabling-instrumentation) for the programmatic alternative with sticky semantics. |
|
||||
| `ENABLE_SENSITIVE_DATA` | `false` | Set to `true` to emit sensitive data (prompts, responses, etc.). |
|
||||
| `ENABLE_CONSOLE_EXPORTERS` | `false` | Set to `true` to add console exporters. Only used by `configure_otel_providers()`. |
|
||||
| `VS_CODE_EXTENSION_PORT` | unset | Port used by the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio#tracing) tracing integration. Only used by `configure_otel_providers()`. |
|
||||
|
||||
All of these are booleans and default to `false`.
|
||||
You can also call `enable_sensitive_telemetry()` from `agent_framework.observability` to opt in to sensitive-data capture programmatically.
|
||||
|
||||
Finally we have `VS_CODE_EXTENSION_PORT` which you can set to a port, which can be used to setup the AI Toolkit for VS Code tracing integration. See [here](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio#tracing) for more details.
|
||||
> **Note**: Sensitive data includes prompts, responses, and tool arguments. Only enable it in development or test environments — it may expose user or system secrets in production.
|
||||
|
||||
The framework will emit observability data when the `ENABLE_INSTRUMENTATION` environment variable is set to `true`. If both are `true` then it will also emit sensitive information. When these are not set, or set to false, you can use the `enable_instrumentation()` function from the `agent_framework.observability` module to turn on instrumentation programmatically. This is useful when you want to control this via code instead of environment variables.
|
||||
### Disabling instrumentation
|
||||
|
||||
> **Note**: Sensitive information includes prompts, responses, and more, and should only be enabled in a development or test environment. It is not recommended to enable this in production environments as it may expose sensitive data.
|
||||
There are two ways to turn Agent Framework's native instrumentation off, and they have **different scopes**:
|
||||
|
||||
The two other variables, `ENABLE_CONSOLE_EXPORTERS` and `VS_CODE_EXTENSION_PORT`, are used to configure where the observability data is sent. Those are only activated when calling `configure_otel_providers()`.
|
||||
| Approach | Scope | Sticky? | When framework code calls `enable_instrumentation()` later, what happens? |
|
||||
|----------|-------|---------|---------------------------------------------------------------------------|
|
||||
| `ENABLE_INSTRUMENTATION=false` in the environment | Initial settings only | No | Instrumentation flips back **on**. |
|
||||
| `disable_instrumentation()` called from code | Process-wide, sticky | Yes | Instrumentation **stays off** — the user-disable intent wins. |
|
||||
|
||||
If you want telemetry off **and want it to stay off**, use `disable_instrumentation()`.
|
||||
|
||||
#### Sticky semantics — why this matters
|
||||
|
||||
Framework integrations and third-party libraries can call `enable_instrumentation()`, `enable_sensitive_telemetry()`, or `configure_otel_providers()` as part of their own setup. For example, `FoundryChatClient.configure_azure_monitor()` calls `enable_instrumentation()` after wiring up Azure Monitor. That's normally what you want — but if **you** have explicitly opted out, you don't want any of those calls to silently re-enable telemetry.
|
||||
|
||||
`disable_instrumentation()` solves this by setting a **sticky** flag on `OBSERVABILITY_SETTINGS` that remains in effect until you explicitly clear it. While the flag is set:
|
||||
|
||||
1. `OBSERVABILITY_SETTINGS.enable_instrumentation` and `enable_sensitive_data` **read as `False`** regardless of the stored value.
|
||||
2. `enable_instrumentation()` and `enable_sensitive_telemetry()` are **no-ops** and log an info-level message.
|
||||
3. `configure_otel_providers()` still configures providers / exporters / views (so a later force-enable can use them), but does not flip instrumentation on.
|
||||
4. Direct attribute writes like `OBSERVABILITY_SETTINGS.enable_instrumentation = True` from any code are **silently dropped** (defense in depth).
|
||||
5. Integrations that consult `OBSERVABILITY_SETTINGS.is_user_disabled` (e.g. `FoundryChatClient.configure_azure_monitor()`, `FoundryAgent.configure_azure_monitor()`) **skip their setup entirely**, so global Azure Monitor providers aren't installed unnecessarily.
|
||||
|
||||
```python
|
||||
from agent_framework.observability import disable_instrumentation
|
||||
|
||||
# After this call, Agent Framework expresses your intent to opt out of telemetry.
|
||||
# Library and framework code is expected to honor that intent and not flip
|
||||
# instrumentation back on (e.g. by calling `enable_instrumentation()`,
|
||||
# `enable_sensitive_telemetry()`, or writing to public attributes on
|
||||
# `OBSERVABILITY_SETTINGS`). The framework actively short-circuits the public
|
||||
# enable paths so the user's intent stays leading. A determined caller can still
|
||||
# pass `force=True` or mutate private (`_`-prefixed) attributes to bypass it,
|
||||
# but those are out-of-contract escape hatches that should not be used by
|
||||
# integrations on the user's behalf.
|
||||
disable_instrumentation()
|
||||
```
|
||||
|
||||
#### Forcing re-enablement after a disable
|
||||
|
||||
To intentionally re-enable telemetry after `disable_instrumentation()`, pass `force=True` to either of the two public enable helpers. This is the only way to clear the sticky disable, so the user's opt-out can only be reversed by a deliberate user opt-in:
|
||||
|
||||
```python
|
||||
from agent_framework.observability import (
|
||||
disable_instrumentation,
|
||||
enable_instrumentation,
|
||||
enable_sensitive_telemetry,
|
||||
)
|
||||
|
||||
disable_instrumentation()
|
||||
|
||||
# Without force=True, these are no-ops while the disable is sticky:
|
||||
enable_instrumentation() # logs info, does nothing
|
||||
enable_sensitive_telemetry() # logs info, does nothing
|
||||
|
||||
# With force=True, the sticky disable is cleared and the call proceeds:
|
||||
enable_instrumentation(force=True)
|
||||
# or
|
||||
enable_sensitive_telemetry(force=True)
|
||||
|
||||
# After a force-enable you can `disable_instrumentation()` again to re-arm
|
||||
# the sticky disable.
|
||||
```
|
||||
|
||||
#### Checking the disable state from integrations
|
||||
|
||||
If you're writing an integration that performs telemetry setup as a side effect (e.g. provisioning a third-party exporter), consult the public read-only `is_user_disabled` property and early-return when it's set:
|
||||
|
||||
```python
|
||||
from agent_framework.observability import OBSERVABILITY_SETTINGS
|
||||
|
||||
if OBSERVABILITY_SETTINGS.is_user_disabled:
|
||||
logger.info(
|
||||
"Skipping telemetry setup because the user called disable_instrumentation()."
|
||||
)
|
||||
return
|
||||
```
|
||||
|
||||
This is what the built-in `FoundryChatClient.configure_azure_monitor()` and `FoundryAgent.configure_azure_monitor()` do — so calling `disable_instrumentation()` reliably prevents Azure Monitor's global providers from being installed by those helpers.
|
||||
|
||||
#### What `disable_instrumentation()` does **not** do
|
||||
|
||||
- It does not tear down OpenTelemetry providers, exporters, or in-flight spans that were already set up before the disable call. It only gates **future** captures by Agent Framework code paths.
|
||||
- It does not stop telemetry from third-party instrumentations (e.g. `azure-monitor-opentelemetry`'s system metrics) that are wired up outside Agent Framework. Configure those separately if needed.
|
||||
- It does not persist across processes. Each Python process starts with the disable flag cleared; if you always want telemetry off in a given environment, set `ENABLE_INSTRUMENTATION=false` as an environment variable in addition to (or instead of) the programmatic call.
|
||||
|
||||
#### Environment variables for `configure_otel_providers()`
|
||||
|
||||
@@ -202,7 +304,8 @@ The `configure_otel_providers()` function automatically reads **standard OpenTel
|
||||
> **Note**: These are standard OpenTelemetry environment variables. See the [OpenTelemetry spec](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) for more details.
|
||||
|
||||
#### Logging
|
||||
Use standard Python logging configuration to align logs with telemetry output.
|
||||
|
||||
Use standard Python logging configuration to align logs with telemetry output:
|
||||
|
||||
```python
|
||||
import logging
|
||||
@@ -212,15 +315,14 @@ logging.basicConfig(
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
```
|
||||
You can control at what level logging happens and thus what logs get exported, you can do this, by adding this:
|
||||
|
||||
To control which logs are exported, adjust the root logger level — other loggers inherit from it by default:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.NOTSET)
|
||||
logging.getLogger().setLevel(logging.NOTSET)
|
||||
```
|
||||
This gets the root logger and sets the level of that, automatically other loggers inherit from that one, and you will get detailed logs in your telemetry.
|
||||
|
||||
## Samples
|
||||
|
||||
@@ -228,36 +330,35 @@ This folder contains different samples demonstrating how to use telemetry in var
|
||||
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| [configure_otel_providers_with_parameters.py](./configure_otel_providers_with_parameters.py) | **Recommended starting point**: Shows how to create custom exporters with specific configuration and pass them to `configure_otel_providers()`. Useful for advanced scenarios. |
|
||||
| [configure_otel_providers_with_env_var.py](./configure_otel_providers_with_env_var.py) | Shows how to setup telemetry using standard OpenTelemetry environment variables (`OTEL_EXPORTER_OTLP_*`). |
|
||||
| [agent_observability.py](./agent_observability.py) | Shows telemetry collection for an agentic application with tool calls using environment variables. |
|
||||
| [foundry_tracing.py](./foundry_tracing.py) | Shows Azure Monitor integration with Foundry for any chat client. |
|
||||
| [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | Advanced: Shows manual setup of exporters and providers with console output. Useful for understanding how observability works under the hood. |
|
||||
| [advanced_zero_code.py](./advanced_zero_code.py) | Advanced: Shows zero-code telemetry setup using the `opentelemetry-enable_instrumentation` CLI tool. |
|
||||
| [workflow_observability.py](./workflow_observability.py) | Shows telemetry collection for a workflow with multiple executors and message passing. |
|
||||
| [configure_otel_providers_with_env_var.py](./configure_otel_providers_with_env_var.py) | **Recommended starting point**: configure telemetry using standard OpenTelemetry environment variables (`OTEL_EXPORTER_OTLP_*`). |
|
||||
| [configure_otel_providers_with_parameters.py](./configure_otel_providers_with_parameters.py) | Create custom exporters with specific configuration and pass them to `configure_otel_providers()`. |
|
||||
| [agent_observability.py](./agent_observability.py) | Telemetry collection for an agentic application with tool calls. |
|
||||
| [foundry_tracing.py](./foundry_tracing.py) | Azure Monitor integration with Microsoft Foundry. |
|
||||
| [workflow_observability.py](./workflow_observability.py) | Telemetry collection for a workflow with multiple executors and message passing. |
|
||||
| [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | Advanced: manual setup of exporters and providers with console output — useful for understanding how observability works under the hood. |
|
||||
| [advanced_zero_code.py](./advanced_zero_code.py) | Advanced: zero-code provider/exporter setup using the `opentelemetry-instrument` CLI wrapper. |
|
||||
|
||||
### Running the samples
|
||||
|
||||
1. Open a terminal and navigate to this folder: `python/samples/02-agents/observability/`. This is necessary for the `.env` file to be read correctly.
|
||||
2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example).
|
||||
> **Note**: You can start with just `ENABLE_INSTRUMENTATION=true` and add `OTEL_EXPORTER_OTLP_ENDPOINT` or other configuration as needed. If no exporters are configured, you can set `ENABLE_CONSOLE_EXPORTERS=true` for console output.
|
||||
3. Choose one environment-loading approach:
|
||||
- **A. Sample-managed loading (current samples):** run from this folder so the sample's `load_dotenv()` call can find `.env`.
|
||||
- **B. Shell/IDE-managed environment:** set/export environment variables directly, or use an IDE run configuration that injects env vars / `.env`.
|
||||
- **C. Explicit env file in code:** pass `env_file_path` to APIs like `configure_otel_providers(env_file_path=".env")` (or your own settings loader path).
|
||||
- **D. CLI-managed env file:** run with `uv` and pass the file explicitly, for example:
|
||||
`uv run --env-file=.env python configure_otel_providers_with_env_var.py`
|
||||
4. Activate your python virtual environment, then run a sample (for example `python configure_otel_providers_with_env_var.py`).
|
||||
1. Open a terminal in this folder (`python/samples/02-agents/observability/`) so that `.env` is found.
|
||||
2. Create a `.env` file if you don't already have one. See [.env.example](./.env.example).
|
||||
> Instrumentation is on by default. Set `OTEL_EXPORTER_OTLP_ENDPOINT` (or other configuration) as needed. With no exporters configured, set `ENABLE_CONSOLE_EXPORTERS=true` for console output.
|
||||
3. Pick an environment-loading approach:
|
||||
- **A. Sample-managed:** run from this folder so the sample's `load_dotenv()` call can find `.env`.
|
||||
- **B. Shell/IDE-managed:** export environment variables, or use an IDE run configuration that injects them.
|
||||
- **C. Explicit env file in code:** pass `env_file_path` to APIs like `configure_otel_providers(env_file_path=".env")`.
|
||||
- **D. CLI-managed:** run with `uv` and pass the file explicitly, e.g. `uv run --env-file=.env python configure_otel_providers_with_env_var.py`.
|
||||
4. Activate your virtual environment, then run a sample (e.g. `python configure_otel_providers_with_env_var.py`).
|
||||
|
||||
> If you do manual provider setup (e.g., Azure Monitor), call `enable_instrumentation()` to turn on Agent Framework telemetry code paths; if you want Agent Framework to configure exporters/providers for you, call `configure_otel_providers(...)`.
|
||||
> If you set up providers manually (e.g. Azure Monitor), Agent Framework instrumentation is still on by default. Call `enable_sensitive_telemetry()` if you also want to capture sensitive data. To have Agent Framework configure exporters and providers for you, call `configure_otel_providers(...)`.
|
||||
|
||||
> Each sample will print the Operation/Trace ID, which can be used later for filtering logs and traces in Application Insights or Aspire Dashboard.
|
||||
> Each sample prints its Operation/Trace ID, which you can use to filter logs and traces in Application Insights or the Aspire Dashboard.
|
||||
|
||||
# Appendix
|
||||
|
||||
## Azure Monitor Queries
|
||||
|
||||
When you are in Azure Monitor and want to have a overall view of the span, use this query in the logs section:
|
||||
For an overall view of a span in Azure Monitor, run this query in the Logs section:
|
||||
|
||||
```kusto
|
||||
dependencies
|
||||
@@ -280,7 +381,8 @@ dependencies
|
||||
```
|
||||
|
||||
### Grafana dashboards with Application Insights data
|
||||
Besides the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
|
||||
|
||||
In addition to the native Application Insights UI, you can use Grafana to visualize the same telemetry data. Two tailored dashboards are available to get you started:
|
||||
|
||||
#### Agent Overview dashboard
|
||||
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
|
||||
@@ -292,117 +394,27 @@ Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-workflow>
|
||||
|
||||
## Migration Guide
|
||||
|
||||
We've done a major update to the observability API in Agent Framework Python SDK. The new API simplifies configuration by relying more on standard OpenTelemetry environment variables and have split the instrumentation from the configuration.
|
||||
Instrumentation is now **enabled by default** (you no longer have to opt in by calling `enable_instrumentation()` at startup), and the way you opt in to capturing sensitive payloads has its own dedicated function.
|
||||
|
||||
If you're updating from a previous version of the Agent Framework, here are the key changes to the observability API:
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Old Variable | New Variable | Notes |
|
||||
|-------------|--------------|-------|
|
||||
| `OTLP_ENDPOINT` | `OTEL_EXPORTER_OTLP_ENDPOINT` | Standard OpenTelemetry env var |
|
||||
| `APPLICATIONINSIGHTS_CONNECTION_STRING` | N/A | Use `configure_azure_monitor()` |
|
||||
| N/A | `ENABLE_CONSOLE_EXPORTERS` | New opt-in flag for console output |
|
||||
|
||||
### OTLP Configuration
|
||||
|
||||
**Before (Deprecated):**
|
||||
```
|
||||
from agent_framework.observability import setup_observability
|
||||
# Via parameter
|
||||
setup_observability(otlp_endpoint="http://localhost:4317")
|
||||
|
||||
# Via environment variable
|
||||
# OTLP_ENDPOINT=http://localhost:4317
|
||||
setup_observability()
|
||||
```
|
||||
|
||||
**After (Current):**
|
||||
```python
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
# Via standard OTEL environment variable (recommended)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
configure_otel_providers()
|
||||
|
||||
# Or via custom exporters
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||
|
||||
configure_otel_providers(exporters=[
|
||||
OTLPSpanExporter(endpoint="http://localhost:4317"),
|
||||
OTLPLogExporter(endpoint="http://localhost:4317"),
|
||||
OTLPMetricExporter(endpoint="http://localhost:4317"),
|
||||
])
|
||||
```
|
||||
|
||||
### Azure Monitor Configuration
|
||||
|
||||
**Before (Deprecated):**
|
||||
```
|
||||
from agent_framework.observability import setup_observability
|
||||
|
||||
setup_observability(
|
||||
applicationinsights_connection_string="InstrumentationKey=...",
|
||||
applicationinsights_live_metrics=True,
|
||||
)
|
||||
```
|
||||
|
||||
**After (Current):**
|
||||
If your code previously did:
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.observability import create_resource, enable_instrumentation
|
||||
from azure.identity import AzureCliCredential
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
|
||||
async def main():
|
||||
# For Microsoft Foundry projects
|
||||
client = FoundryChatClient(
|
||||
project_endpoint="https://your-project.services.ai.azure.com",
|
||||
model="gpt-4o",
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
await client.configure_azure_monitor(enable_live_metrics=True)
|
||||
|
||||
# For non-Azure AI projects
|
||||
configure_azure_monitor(
|
||||
connection_string="InstrumentationKey=...",
|
||||
resource=create_resource(),
|
||||
enable_live_metrics=True,
|
||||
)
|
||||
enable_instrumentation()
|
||||
enable_instrumentation(enable_sensitive_data=True)
|
||||
```
|
||||
|
||||
### Console Output
|
||||
replace it with:
|
||||
|
||||
**Before (Deprecated):**
|
||||
```
|
||||
from agent_framework.observability import setup_observability
|
||||
|
||||
# Console was used as automatic fallback
|
||||
setup_observability() # Would output to console if no exporters configured
|
||||
```
|
||||
|
||||
**After (Current):**
|
||||
```python
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
|
||||
# Console exporters are now opt-in
|
||||
# ENABLE_CONSOLE_EXPORTERS=true
|
||||
configure_otel_providers()
|
||||
|
||||
# Or programmatically
|
||||
configure_otel_providers(enable_console_exporters=True)
|
||||
enable_sensitive_telemetry()
|
||||
```
|
||||
|
||||
### Benefits of New API
|
||||
`enable_sensitive_telemetry()` ensures that instrumentation is on and turns sensitive-event capture on in one call. `enable_instrumentation()` still exists for the rare case where you want to programmatically force instrumentation on without enabling sensitive data (e.g. to override `ENABLE_INSTRUMENTATION=false`), and it now also accepts `force=True` to clear a previous `disable_instrumentation()` — see [Disabling instrumentation](#disabling-instrumentation).
|
||||
|
||||
1. **Standards Compliant**: Uses standard OpenTelemetry environment variables
|
||||
2. **Simpler**: Less configuration needed, more relies on environment
|
||||
3. **Flexible**: Easy to add custom exporters alongside environment-based ones
|
||||
4. **Cleaner Separation**: Azure Monitor setup is in Azure-specific client
|
||||
5. **Better Compatibility**: Works with any OTEL-compatible tool (Jaeger, Zipkin, Prometheus, etc.)
|
||||
> **Note**: Sensitive data includes prompts, responses, and tool arguments. Only enable it in development or test environments — it may expose user or system secrets in production.
|
||||
|
||||
## Aspire Dashboard
|
||||
|
||||
@@ -437,7 +449,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
Or set it as an environment variable when running your samples:
|
||||
|
||||
```bash
|
||||
ENABLE_INSTRUMENTATION=true OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 python configure_otel_providers_with_env_var.py
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 python configure_otel_providers_with_env_var.py
|
||||
```
|
||||
|
||||
### Viewing telemetry data
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
from agent_framework import Message, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework.observability import enable_sensitive_telemetry
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from opentelemetry._logs import set_logger_provider
|
||||
@@ -135,7 +135,8 @@ async def main():
|
||||
setup_logging()
|
||||
setup_tracing()
|
||||
setup_metrics()
|
||||
enable_instrumentation()
|
||||
# Instrumentation is enabled by default; call this to also capture sensitive data.
|
||||
enable_sensitive_telemetry()
|
||||
|
||||
await run_chat_client()
|
||||
|
||||
|
||||
@@ -19,13 +19,20 @@ if TYPE_CHECKING:
|
||||
|
||||
"""
|
||||
This sample shows how you can configure observability of an application with zero code changes.
|
||||
It relies on the OpenTelemetry auto-instrumentation capabilities, and the observability setup
|
||||
is done via environment variables.
|
||||
|
||||
Follow the install guidance from https://opentelemetry.io/docs/zero-code/python/ to install the OpenTelemetry CLI tool,
|
||||
when using `uv` there are some additional steps, so follow the instructions carefully.
|
||||
Agent Framework is natively instrumented with OpenTelemetry, so no auto-instrumentation of the
|
||||
framework itself is required. Running the `opentelemetry-instrument` CLI wrapper simply configures
|
||||
the global tracer/meter providers and exporters from environment variables (or CLI flags) at
|
||||
process startup, so the application code does not need to set them up explicitly. The native
|
||||
spans/metrics emitted by Agent Framework are then picked up by that globally configured pipeline.
|
||||
|
||||
And setup a local OpenTelemetry Collector instance to receive the traces and metrics (and update the endpoint below).
|
||||
See: https://opentelemetry.io/docs/zero-code/python/
|
||||
|
||||
Install the OpenTelemetry CLI tool following the guidance above (when using `uv` there are some
|
||||
additional steps, so follow the instructions carefully).
|
||||
|
||||
Then setup a local OpenTelemetry Collector instance to receive the traces and metrics (and update
|
||||
the endpoint below).
|
||||
|
||||
Then you can run:
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Monty local code interpreter
|
||||
|
||||
Demonstrates the standalone [Monty](https://github.com/pydantic/monty)
|
||||
`MontyExecuteCodeTool` — a sandboxed local code interpreter that the agent can
|
||||
invoke directly. Two patterns are shown:
|
||||
|
||||
| File | Pattern |
|
||||
|------|---------|
|
||||
| [`monty_code_interpreter.py`](monty_code_interpreter.py) | **Standalone tool** — `MontyExecuteCodeTool` is added to the agent tool list and self-describes its sandbox tools, so no extra agent instructions are needed. Best for quick prototyping. |
|
||||
| [`monty_code_interpreter_manual_wiring.py`](monty_code_interpreter_manual_wiring.py) | **Manual static wiring** — sandbox tools and CodeAct instructions are built once and passed to the `Agent` constructor alongside a direct-only tool (`send_email`). Best when the tool set is fixed for the agent's lifetime. |
|
||||
|
||||
For the recommended provider-driven pattern (with dynamic tool / capability
|
||||
management), see
|
||||
[`../../context_providers/code_act/`](../../context_providers/code_act/).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install agent-framework agent-framework-monty --pre
|
||||
```
|
||||
|
||||
> `agent-framework-monty` is an alpha package and is not yet part of
|
||||
> `agent-framework[all]`. The `--pre` flag is required.
|
||||
>
|
||||
> Monty is cross-platform and has no hypervisor/WASM backend dependency.
|
||||
> Inside the sandbox, OS / filesystem / network calls are blocked
|
||||
> (`PermissionError`); registered host tools retain full Python access.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Azure AI Foundry project endpoint (`FOUNDRY_PROJECT_ENDPOINT`)
|
||||
- A deployed model (`FOUNDRY_MODEL`)
|
||||
- Azure CLI authenticated (`az login`)
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python monty_code_interpreter.py
|
||||
python monty_code_interpreter_manual_wiring.py
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_monty import MontyExecuteCodeTool
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
"""This sample demonstrates the standalone Monty execute_code tool.
|
||||
|
||||
The sample adds `MontyExecuteCodeTool` directly to the agent. The tool's own
|
||||
description advertises the registered sandbox tools (as typed async functions
|
||||
and via `call_tool(...)`) plus the Monty DSL, so no extra CodeAct-specific
|
||||
agent instructions are required.
|
||||
|
||||
Note: `agent-framework-monty` is an alpha package and is not yet part of
|
||||
`agent-framework[all]`. Install it explicitly with:
|
||||
|
||||
pip install agent-framework agent-framework-monty --pre
|
||||
"""
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def compute(
|
||||
operation: Annotated[
|
||||
Literal["add", "subtract", "multiply", "divide"],
|
||||
"Math operation: add, subtract, multiply, or divide.",
|
||||
],
|
||||
a: Annotated[float, "First numeric operand."],
|
||||
b: Annotated[float, "Second numeric operand."],
|
||||
) -> float:
|
||||
"""Perform a math operation used by sandboxed code."""
|
||||
operations = {
|
||||
"add": a + b,
|
||||
"subtract": a - b,
|
||||
"multiply": a * b,
|
||||
"divide": a / b if b else float("inf"),
|
||||
}
|
||||
return operations[operation]
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def fetch_data(
|
||||
table: Annotated[str, "Name of the simulated table to query."],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch simulated records from a named table."""
|
||||
data: dict[str, list[dict[str, Any]]] = {
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice", "role": "admin"},
|
||||
{"id": 2, "name": "Bob", "role": "user"},
|
||||
{"id": 3, "name": "Charlie", "role": "admin"},
|
||||
],
|
||||
"products": [
|
||||
{"id": 101, "name": "Widget", "price": 9.99},
|
||||
{"id": 102, "name": "Gadget", "price": 19.99},
|
||||
],
|
||||
}
|
||||
return data.get(table, [])
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the standalone Monty execute_code sample."""
|
||||
# 1. Create the packaged execute_code tool and register sandbox tools on it.
|
||||
execute_code = MontyExecuteCodeTool(
|
||||
tools=[compute, fetch_data],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# 2. Create the client and the agent.
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name="MontyExecuteCodeToolAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
tools=execute_code,
|
||||
)
|
||||
|
||||
# 3. Run one request through the direct-tool surface.
|
||||
print("=" * 60)
|
||||
print("Monty execute_code tool sample")
|
||||
print("=" * 60)
|
||||
query = (
|
||||
"Fetch all users, find admins, multiply 6*7, and print the users, admins, "
|
||||
"and multiplication result. Use one execute_code call."
|
||||
)
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
"""
|
||||
Sample output (shape only):
|
||||
|
||||
============================================================
|
||||
Monty execute_code tool sample
|
||||
============================================================
|
||||
User: Fetch all users, find admins, multiply 6*7, ...
|
||||
Agent: ...
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_monty import MontyExecuteCodeTool
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
"""This sample demonstrates manual static wiring of Monty CodeAct without a provider.
|
||||
|
||||
Instead of using `MontyCodeActProvider` with `context_providers=`, this sample
|
||||
creates a `MontyExecuteCodeTool` directly, extracts its CodeAct instructions
|
||||
once, and passes both to the `Agent` constructor at build time.
|
||||
|
||||
This avoids the per-run provider lifecycle (`before_run` / `after_run`) and is
|
||||
well-suited when the tool registry is fixed for the agent's lifetime. The
|
||||
tradeoff is that dynamic tool changes between runs are not supported - any
|
||||
mutations to the tool would not update the agent's instructions automatically.
|
||||
|
||||
Note: `agent-framework-monty` is an alpha package and is not yet part of
|
||||
`agent-framework[all]`. Install it explicitly with:
|
||||
|
||||
pip install agent-framework agent-framework-monty --pre
|
||||
"""
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def compute(
|
||||
operation: Annotated[
|
||||
Literal["add", "subtract", "multiply", "divide"],
|
||||
"Math operation: add, subtract, multiply, or divide.",
|
||||
],
|
||||
a: Annotated[float, "First numeric operand."],
|
||||
b: Annotated[float, "Second numeric operand."],
|
||||
) -> float:
|
||||
"""Perform a math operation used by sandboxed code."""
|
||||
operations = {
|
||||
"add": a + b,
|
||||
"subtract": a - b,
|
||||
"multiply": a * b,
|
||||
"divide": a / b if b else float("inf"),
|
||||
}
|
||||
return operations[operation]
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def fetch_data(
|
||||
table: Annotated[str, "Name of the simulated table to query."],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch simulated records from a named table."""
|
||||
data: dict[str, list[dict[str, Any]]] = {
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice", "role": "admin"},
|
||||
{"id": 2, "name": "Bob", "role": "user"},
|
||||
{"id": 3, "name": "Charlie", "role": "admin"},
|
||||
],
|
||||
"products": [
|
||||
{"id": 101, "name": "Widget", "price": 9.99},
|
||||
{"id": 102, "name": "Gadget", "price": 19.99},
|
||||
],
|
||||
}
|
||||
return data.get(table, [])
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def send_email(
|
||||
to: Annotated[str, "Recipient email address."],
|
||||
subject: Annotated[str, "Email subject line."],
|
||||
body: Annotated[str, "Email body text."],
|
||||
) -> str:
|
||||
"""Simulate sending an email (direct-only tool, not available inside the sandbox)."""
|
||||
return f"Email sent to {to}: {subject}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the manual static-wiring Monty sample."""
|
||||
# 1. Create the execute_code tool and register sandbox tools on it.
|
||||
execute_code = MontyExecuteCodeTool(
|
||||
tools=[compute, fetch_data],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# 2. Build CodeAct instructions once. Setting tools_visible_to_model=False
|
||||
# tells the instructions builder that sandbox tools are not in the agent's
|
||||
# direct tool list, so the model must call them inside execute_code.
|
||||
codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False)
|
||||
|
||||
# 3. Create the client and the agent with everything wired at construction time.
|
||||
# - send_email is a direct-only tool (not available inside the sandbox).
|
||||
# - execute_code carries sandbox tools (compute, fetch_data) for Monty.
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name="MontyManualWiringAgent",
|
||||
instructions=f"You are a helpful assistant.\n\n{codeact_instructions}",
|
||||
tools=[send_email, execute_code],
|
||||
)
|
||||
|
||||
# 4. Run a request that exercises both the sandbox and the direct tool.
|
||||
print("=" * 60)
|
||||
print("Manual static-wiring Monty CodeAct sample")
|
||||
print("=" * 60)
|
||||
query = (
|
||||
"Fetch all users, find admins, multiply 6*7, and print the users, admins, "
|
||||
"and multiplication result. Use one execute_code call. "
|
||||
"Then send an email to admin@example.com summarising the results."
|
||||
)
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
"""
|
||||
Sample output (shape only):
|
||||
|
||||
============================================================
|
||||
Manual static-wiring Monty CodeAct sample
|
||||
============================================================
|
||||
User: Fetch all users, find admins, multiply 6*7, ...
|
||||
Agent: ...
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -22,9 +22,6 @@ What this example shows:
|
||||
- executor_completed events (type='executor_completed') contain the messages sent via ctx.send_message() in event.data
|
||||
- How to generically observe all executor I/O through workflow streaming events
|
||||
|
||||
This approach allows you to enable_instrumentation any workflow for observability without
|
||||
changing the executor implementations.
|
||||
|
||||
Prerequisites:
|
||||
- No external services required.
|
||||
"""
|
||||
|
||||
@@ -18,7 +18,8 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew
|
||||
| 8 | [Azure AI Search RAG](responses/08_azure_search_rag/) | An agent with Retrieval Augmented Generation (RAG) capabilities backed by Azure AI Search, grounding answers in documents indexed in a pre-provisioned search index. |
|
||||
| 9 | [Foundry Skills](responses/09_foundry_skills/) | An agent that uploads `SKILL.md` files to the Foundry Skills REST API and downloads them at startup, decoupling tone/policy guidelines from agent code. |
|
||||
| 10 | [Foundry Memory](responses/10_foundry_memory/) | An agent with persistent semantic memory backed by an Azure AI Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. |
|
||||
| 11 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
|
||||
| 11 | [Monty CodeAct](responses/11_monty_codeact/) | An agent with a Monty-backed CodeAct context provider, exposing a single `execute_code` tool that runs Python in a [pydantic-monty](https://github.com/pydantic/monty) interpreter and invokes typed host tools (`compute`, `fetch_data`) from inside the sandbox. Uses the alpha `agent-framework-monty` package. |
|
||||
| 12 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
|
||||
|
||||
### Invocations API
|
||||
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . user_agent/
|
||||
|
||||
+17
-3
@@ -10,6 +10,20 @@ You can also create a Foundry Toolbox in the Foundry portal. Read more about it
|
||||
|
||||
> If you set up a project with this sample and provision the resources using `azd provision`, a Foundry Toolbox will be created with the specified tools in [`agent.manifest.yaml`](agent.manifest.yaml).
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
You can connect to MCP servers in Foundry Toolbox that use different authentication methods. This sample demonstrates the following authentication methods:
|
||||
|
||||
- **No authentication**: The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs`
|
||||
- **Key-based authentication**: The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication.
|
||||
- **OAuth2 authentication (managed)**: The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 for authentication.
|
||||
- **Agent identity authentication**: The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` (Azure Language MCP server) with agent identity for authentication.
|
||||
- **Entra Pass-through authentication**: The tool requires an Entra pass-through token to authenticate. Sample MCP server: Microsoft Outlook MCP server with Entra pass-through for authentication.
|
||||
|
||||
> Definitions of these authentication methods can be found in the [agent.manifest.yaml](agent.manifest.yaml) file in this sample.
|
||||
|
||||
There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md).
|
||||
|
||||
## How It Works
|
||||
|
||||
### Model Integration
|
||||
@@ -31,20 +45,20 @@ An extra environment variable must be set to point to the toolbox MCP endpoint.
|
||||
**Option A – Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development):
|
||||
|
||||
```bash
|
||||
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
|
||||
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
|
||||
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
**Option B – Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`):
|
||||
|
||||
The agent derives the endpoint at runtime as:
|
||||
```
|
||||
{FOUNDRY_PROJECT_ENDPOINT}/toolsets/{TOOLBOX_NAME}/mcp?api-version=v1
|
||||
{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1
|
||||
```
|
||||
|
||||
When deployed via `azd provision`, the scaffolding injects `TOOLBOX_NAME=agent-tools` and `FOUNDRY_PROJECT_ENDPOINT` automatically from the provisioned resources declared in [`agent.manifest.yaml`](agent.manifest.yaml).
|
||||
|
||||
+85
-9
@@ -18,16 +18,92 @@ template:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: TOOLBOX_NAME
|
||||
value: "agent-tools"
|
||||
value: "agent-tools-2"
|
||||
# parameters:
|
||||
# properties:
|
||||
# - name: mcp_endpoint
|
||||
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest
|
||||
# secret: false
|
||||
# description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) that does not require authentication
|
||||
# - name: github_pat
|
||||
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest.
|
||||
# # Only needed when the GitHub MCP connection is configured to use the `github-mcp-pat-conn`
|
||||
# # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection
|
||||
# # instead, you can leave this empty.
|
||||
# secret: true
|
||||
# description: GitHub Personal Access Token used to authenticate with the GitHub MCP server (only needed when using the PAT connection; press Enter if using OAuth2 instead)
|
||||
# - name: language_mcp_entra_audience
|
||||
# secret: false
|
||||
# description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/)
|
||||
# - name: language_mcp_target_url
|
||||
# secret: false
|
||||
# description: URL of the Azure Language MCP server that accepts agent identity tokens (e.g. https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview)
|
||||
# - name: outlook_mail_entra_audience
|
||||
# secret: false
|
||||
# description: Entra ID audience for the Outlook Mail MCP server
|
||||
# - name: outlook_mail_entra_mcp_target
|
||||
# secret: false
|
||||
# description: URL of the Outlook Mail MCP server that accepts user Entra tokens
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
- kind: toolbox
|
||||
name: agent-tools
|
||||
tools:
|
||||
- type: web_search
|
||||
name: web_search
|
||||
- type: code_interpreter
|
||||
name: code_interpreter
|
||||
|
||||
# - kind: connection
|
||||
# # A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server
|
||||
# name: github-mcp-pat-conn
|
||||
# category: RemoteTool
|
||||
# authType: CustomKeys
|
||||
# target: https://api.githubcopilot.com/mcp
|
||||
# credentials:
|
||||
# type: CustomKeys
|
||||
# keys:
|
||||
# Authorization: "Bearer {{ github_pat }}"
|
||||
# - kind: connection
|
||||
# # A connection that uses OAuth2 to authenticate with the GitHub MCP server
|
||||
# name: github-mcp-oauth-conn
|
||||
# category: RemoteTool
|
||||
# authType: OAuth2
|
||||
# target: https://api.githubcopilot.com/mcp
|
||||
# connectorName: foundrygithubmcp
|
||||
# credentials:
|
||||
# type: OAuth2
|
||||
# clientId: managed
|
||||
# clientSecret: managed
|
||||
# - kind: connection
|
||||
# name: language-mcp-conn
|
||||
# category: RemoteTool
|
||||
# authType: AgenticIdentity
|
||||
# audience: "{{ language_mcp_entra_audience }}"
|
||||
# target: "{{ language_mcp_target_url }}"
|
||||
# # - kind: connection
|
||||
# # name: outlook-mail-conn
|
||||
# # category: RemoteTool
|
||||
# # authType: UserEntraToken
|
||||
# # audience: "{{ outlook_mail_entra_audience }}"
|
||||
# # target: "{{ outlook_mail_entra_mcp_target }}"
|
||||
# - kind: toolbox
|
||||
# name: agent-tools
|
||||
# tools:
|
||||
# - type: web_search
|
||||
# name: web_search
|
||||
# - type: code_interpreter
|
||||
# name: code_interpreter
|
||||
# # - type: mcp
|
||||
# # # This MCP tool doesn't require authentication
|
||||
# # server_label: noauth_mcp
|
||||
# # server_url: "{{ mcp_endpoint }}"
|
||||
# # require_approval: "never"
|
||||
# - type: mcp
|
||||
# # This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2
|
||||
# server_label: github
|
||||
# project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication
|
||||
# require_approval: "never"
|
||||
# - type: mcp
|
||||
# # This MCP tool uses the Azure Language MCP server with agent identity for authentication
|
||||
# server_label: language-mcp
|
||||
# project_connection_id: language-mcp-conn
|
||||
# require_approval: "never"
|
||||
# # - type: mcp
|
||||
# # server_label: outlook-mail
|
||||
# # project_connection_id: outlook-mail-conn
|
||||
# # require_approval: "never"
|
||||
|
||||
+46
-33
@@ -3,12 +3,11 @@
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -16,7 +15,7 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _resolve_toolbox_endpoint() -> str:
|
||||
def resolve_toolbox_endpoint() -> str:
|
||||
"""Resolve the toolbox MCP endpoint URL.
|
||||
|
||||
Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to
|
||||
@@ -29,47 +28,61 @@ def _resolve_toolbox_endpoint() -> str:
|
||||
return endpoint
|
||||
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
|
||||
toolbox_name = os.environ["TOOLBOX_NAME"]
|
||||
return f"{project_endpoint}/toolsets/{toolbox_name}/mcp?api-version=v1"
|
||||
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
|
||||
|
||||
|
||||
def make_toolbox_header_provider(credential: TokenCredential) -> Callable[[dict[str, Any]], dict[str, str]]:
|
||||
"""Build a header_provider that injects a fresh Azure AI bearer token on every MCP request."""
|
||||
get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
|
||||
class ToolboxAuth(httpx.Auth):
|
||||
"""Injects a fresh bearer token on every request."""
|
||||
|
||||
def provide(_kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
}
|
||||
def __init__(self, token_provider: Callable[[], str]):
|
||||
self._get_token = token_provider
|
||||
|
||||
return provide
|
||||
def auth_flow(self, request: httpx.Request):
|
||||
request.headers["Authorization"] = f"Bearer {self._get_token()}"
|
||||
yield request
|
||||
|
||||
|
||||
async def main():
|
||||
credential = DefaultAzureCredential()
|
||||
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=credential,
|
||||
)
|
||||
# Create the toolbox
|
||||
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
|
||||
|
||||
toolbox_tool = MCPStreamableHTTPTool(
|
||||
name="foundry_toolbox",
|
||||
description="Tools exposed by the configured Foundry toolbox",
|
||||
url=_resolve_toolbox_endpoint(),
|
||||
header_provider=make_toolbox_header_provider(credential),
|
||||
load_prompts=False,
|
||||
)
|
||||
# Resolve the endpoint once and derive the tool name from the same source: when
|
||||
# ``TOOLBOX_NAME`` isn't explicitly set, parse it out of the resolved URL so the
|
||||
# tool's local name and the upstream toolbox always agree.
|
||||
toolbox_endpoint = resolve_toolbox_endpoint()
|
||||
toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
auth=ToolboxAuth(token_provider),
|
||||
headers={"Foundry-Features": "Toolboxes=V1Preview"},
|
||||
timeout=120.0,
|
||||
) as http_client:
|
||||
toolbox = MCPStreamableHTTPTool(
|
||||
name=toolbox_name,
|
||||
url=toolbox_endpoint,
|
||||
http_client=http_client,
|
||||
load_prompts=False,
|
||||
)
|
||||
|
||||
# Create the chat client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a friendly assistant. Keep your answers brief.",
|
||||
tools=toolbox,
|
||||
# History will be managed by the hosting infrastructure, thus there
|
||||
# is no need to store history by the service. Learn more at:
|
||||
# https://developers.openai.com/api/reference/resources/responses/methods/create
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
async with Agent(
|
||||
client=client,
|
||||
instructions="You are a friendly assistant. Keep your answers brief.",
|
||||
tools=toolbox_tool,
|
||||
# History will be managed by the hosting infrastructure, thus there
|
||||
# is no need to store history by the service. Learn more at:
|
||||
# https://developers.openai.com/api/reference/resources/responses/methods/create
|
||||
default_options={"store": False},
|
||||
) as agent:
|
||||
server = ResponsesHostServer(agent)
|
||||
await server.run_async()
|
||||
|
||||
|
||||
+4
-2
@@ -1,2 +1,4 @@
|
||||
agent-framework
|
||||
agent-framework-foundry-hosting
|
||||
# agent-framework
|
||||
# agent-framework-foundry-hosting
|
||||
|
||||
mcp>=1.24.0,<2
|
||||
|
||||
@@ -21,9 +21,10 @@ This agent uses four tools:
|
||||
1. **Get Current Working Directory Tool (`get_cwd`)** – Returns the current working directory of the agent host process.
|
||||
2. **List Files Tool (`list_files`)** – Lists the files in a specified directory.
|
||||
3. **Read File Tool (`read_file`)** – Reads the contents of a specified file.
|
||||
4. **Code Interpreter Tool (`code_interpreter`)** – Allows the agent to execute Python code in a safe.
|
||||
4. **Code Interpreter Tool (`code_interpreter`)** – Allows the agent to execute Python code in a safe sandboxed environment.
|
||||
5. **Web Search Tool (`web_search`)** – Allows the agent to perform web searches using the Bing Search API.
|
||||
|
||||
> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool is a managed tool provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/).
|
||||
> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool and web search tool are managed tools provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/).
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
@@ -34,20 +35,20 @@ An extra environment variable must be set to point to the toolbox MCP endpoint.
|
||||
**Option A – Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development):
|
||||
|
||||
```bash
|
||||
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
|
||||
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
|
||||
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
**Option B – Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`):
|
||||
|
||||
The agent derives the endpoint at runtime as:
|
||||
```
|
||||
{FOUNDRY_PROJECT_ENDPOINT}/toolsets/{TOOLBOX_NAME}/mcp?api-version=v1
|
||||
{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1
|
||||
```
|
||||
|
||||
When deployed via `azd provision`, the scaffolding injects `TOOLBOX_NAME=agent-tools` and `FOUNDRY_PROJECT_ENDPOINT` automatically from the provisioned resources declared in [`agent.manifest.yaml`](agent.manifest.yaml).
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -16,7 +15,7 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _resolve_toolbox_endpoint() -> str:
|
||||
def resolve_toolbox_endpoint() -> str:
|
||||
"""Resolve the toolbox MCP endpoint URL.
|
||||
|
||||
Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to
|
||||
@@ -29,19 +28,18 @@ def _resolve_toolbox_endpoint() -> str:
|
||||
return endpoint
|
||||
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
|
||||
toolbox_name = os.environ["TOOLBOX_NAME"]
|
||||
return f"{project_endpoint}/toolsets/{toolbox_name}/mcp?api-version=v1"
|
||||
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
|
||||
|
||||
|
||||
def make_toolbox_header_provider(credential: TokenCredential) -> Callable[[dict[str, Any]], dict[str, str]]:
|
||||
"""Build a header_provider that injects a fresh Azure AI bearer token on every MCP request."""
|
||||
get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
|
||||
class ToolboxAuth(httpx.Auth):
|
||||
"""Injects a fresh bearer token on every request."""
|
||||
|
||||
def provide(_kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
}
|
||||
def __init__(self, token_provider: Callable[[], str]):
|
||||
self._get_token = token_provider
|
||||
|
||||
return provide
|
||||
def auth_flow(self, request: httpx.Request):
|
||||
request.headers["Authorization"] = f"Bearer {self._get_token()}"
|
||||
yield request
|
||||
|
||||
|
||||
@tool(description="Get the current working directory.", approval_mode="never_require")
|
||||
@@ -75,39 +73,47 @@ def read_file(file_path: str) -> str:
|
||||
async def main():
|
||||
credential = DefaultAzureCredential()
|
||||
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=credential,
|
||||
)
|
||||
# Create the toolbox
|
||||
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
|
||||
|
||||
# Connect to the toolbox MCP endpoint and expose only the code_interpreter tool.
|
||||
# The toolbox deployed has two tools: (see agent.manifest.yaml)
|
||||
# - `code_interpreter`
|
||||
# - `web_search`
|
||||
# We only need the `code_interpreter` tool for this sample.
|
||||
toolbox_tool = MCPStreamableHTTPTool(
|
||||
name="foundry_toolbox",
|
||||
description="Tools exposed by the configured Foundry toolbox",
|
||||
url=_resolve_toolbox_endpoint(),
|
||||
header_provider=make_toolbox_header_provider(credential),
|
||||
load_prompts=False,
|
||||
allowed_tools=["code_interpreter"],
|
||||
)
|
||||
# Resolve the endpoint once and derive the tool name from the same source: when
|
||||
# ``TOOLBOX_NAME`` isn't explicitly set, parse it out of the resolved URL so the
|
||||
# tool's local name and the upstream toolbox always agree.
|
||||
toolbox_endpoint = resolve_toolbox_endpoint()
|
||||
toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
async with Agent(
|
||||
client=client,
|
||||
instructions=(
|
||||
"You are a friendly assistant. Keep your answers brief. "
|
||||
"Make sure all mathematical calculations are performed using the code interpreter "
|
||||
"instead of mental arithmetic."
|
||||
),
|
||||
tools=[get_cwd, list_files, read_file, toolbox_tool],
|
||||
# History will be managed by the hosting infrastructure, thus there
|
||||
# is no need to store history by the service. Learn more at:
|
||||
# https://developers.openai.com/api/reference/resources/responses/methods/create
|
||||
default_options={"store": False},
|
||||
) as agent:
|
||||
async with httpx.AsyncClient(
|
||||
auth=ToolboxAuth(token_provider),
|
||||
headers={"Foundry-Features": "Toolboxes=V1Preview"},
|
||||
timeout=120.0,
|
||||
) as http_client:
|
||||
toolbox = MCPStreamableHTTPTool(
|
||||
name=toolbox_name,
|
||||
url=toolbox_endpoint,
|
||||
http_client=http_client,
|
||||
load_prompts=False,
|
||||
)
|
||||
|
||||
# Create the chat client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions=(
|
||||
"You are a friendly assistant. Keep your answers brief. "
|
||||
"Make sure all mathematical calculations are performed using the code interpreter "
|
||||
"instead of mental arithmetic."
|
||||
),
|
||||
tools=[get_cwd, list_files, read_file, toolbox],
|
||||
# History will be managed by the hosting infrastructure, thus there
|
||||
# is no need to store history by the service. Learn more at:
|
||||
# https://developers.openai.com/api/reference/resources/responses/methods/create
|
||||
default_options={"store": False},
|
||||
)
|
||||
server = ResponsesHostServer(agent)
|
||||
await server.run_async()
|
||||
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
ENABLE_INSTRUMENTATION=true
|
||||
ENABLE_SENSITIVE_DATA=true
|
||||
+1
-1
@@ -16,7 +16,7 @@ The agent is hosted using the [Agent Framework](https://github.com/microsoft/age
|
||||
|
||||
### Instrumentation
|
||||
|
||||
Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution, but it's turned off by default. This sample demonstrates how to enable instrumentation via environment variables in `agent.manifest.yaml` and `agent.yaml`. The relevant environment variables are `ENABLE_INSTRUMENTATION` and `ENABLE_SENSITIVE_DATA`, which can be set to `true` to enable diagnostics and capture sensitive events respectively.
|
||||
Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution. Instrumentation is enabled by default. To also capture sensitive event payloads (prompts, tool arguments, etc.) set `ENABLE_SENSITIVE_DATA=true`. This sample demonstrates how to manage these settings via environment variables in `agent.manifest.yaml` and `agent.yaml`.
|
||||
|
||||
Foundry Hosted Agent has built-in observability thus you don't need to set up exporters manually to capture telemetry from your code. The traces, metrics, and logs generated by the agent are automatically collected and made available through Foundry's observability stack via Azure Monitor/Application Insights. The `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable is injected when the agent is deployed to Foundry, however it is still required to be set in your environment if you want to run the agent host locally and have telemetry sent to Application Insights from your local environment.
|
||||
|
||||
|
||||
-2
@@ -17,8 +17,6 @@ template:
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: ENABLE_INSTRUMENTATION
|
||||
value: true
|
||||
- name: ENABLE_SENSITIVE_DATA
|
||||
value: true
|
||||
resources:
|
||||
|
||||
+3
-5
@@ -5,12 +5,10 @@ protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: '0.25'
|
||||
memory: '0.5Gi'
|
||||
cpu: "0.25"
|
||||
memory: "0.5Gi"
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: ENABLE_INSTRUMENTATION
|
||||
value: true
|
||||
- name: ENABLE_SENSITIVE_DATA
|
||||
value: true
|
||||
value: true
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Bring in the `uv` binary from a pinned Astral image. Update this tag intentionally;
|
||||
# `latest` would make rebuilds non-deterministic.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /usr/local/bin/
|
||||
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_PROJECT_ENVIRONMENT=/app/.venv \
|
||||
PATH="/app/.venv/bin:${PATH}"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Sync dependencies first to maximize Docker layer caching.
|
||||
COPY pyproject.toml ./
|
||||
RUN uv sync --no-install-project --no-cache
|
||||
|
||||
# Now copy the rest of the agent and finalize the environment.
|
||||
COPY . ./
|
||||
RUN uv sync --no-cache
|
||||
|
||||
EXPOSE 8088
|
||||
|
||||
CMD ["uv", "run", "--no-sync", "python", "main.py"]
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent with a
|
||||
**Monty-backed CodeAct context provider** hosted using the **Responses protocol**.
|
||||
The model receives one tool (`execute_code`) and runs Python inside a
|
||||
[Monty](https://github.com/pydantic/monty) interpreter; the registered host
|
||||
tools (`compute`, `fetch_data`) are only reachable from inside the sandbox via
|
||||
typed `await compute(...)` calls or the generic `call_tool(...)` fallback.
|
||||
|
||||
> [!NOTE]
|
||||
> `agent-framework-monty` is an **alpha** package, so the `pyproject.toml`
|
||||
> sets `[tool.uv] prerelease = "allow"` to let `uv sync` pick up the
|
||||
> `1.0.0a*` release from PyPI.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Model Integration
|
||||
|
||||
The agent uses `FoundryChatClient` to create a Responses client from the project
|
||||
endpoint and the model deployment. The agent supports both streaming (SSE
|
||||
events) and non-streaming (JSON) response modes.
|
||||
|
||||
See [main.py](main.py) for the full implementation.
|
||||
|
||||
### CodeAct context provider
|
||||
|
||||
`MontyCodeActProvider` is added to the agent via `context_providers=[...]`. On
|
||||
every run it injects:
|
||||
|
||||
- An `execute_code` tool that runs Python in the Monty interpreter.
|
||||
- Dynamic CodeAct instructions describing the available host tools and DSL.
|
||||
|
||||
The host tools (`compute`, `fetch_data`) are **not** exposed as direct agent
|
||||
tools — the model can only call them from inside `execute_code`, either as
|
||||
typed async functions (`await compute(operation="multiply", a=6, b=7)`) or via
|
||||
the generic `call_tool("compute", operation="multiply", a=6, b=7)` fallback.
|
||||
Code is type-checked against the host tool signatures using
|
||||
[ty](https://docs.astral.sh/ty/) before any tool runs.
|
||||
|
||||
OS-level access (filesystem, network, subprocess) is blocked inside the
|
||||
sandbox; the registered host tools retain full Python access.
|
||||
|
||||
### Observability
|
||||
|
||||
Agent Framework's [native OpenTelemetry instrumentation](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) is enabled by setting these env vars in `agent.yaml` / `agent.manifest.yaml`:
|
||||
|
||||
- `ENABLE_INSTRUMENTATION=true` — turns on the framework's span/metric/log emitters.
|
||||
- `ENABLE_SENSITIVE_DATA=true` — includes prompts, tool inputs, tool outputs, and completions in telemetry. **Dev/test only.**
|
||||
|
||||
`main.py` wires Azure Monitor at startup:
|
||||
|
||||
1. Reads `APPLICATIONINSIGHTS_CONNECTION_STRING` (Foundry hosting injects this automatically for the project's attached Application Insights resource; set it yourself when running locally).
|
||||
2. Calls `azure.monitor.opentelemetry.configure_azure_monitor(connection_string=...)` to register Azure Monitor exporters with the global OTel tracer/meter/logger providers.
|
||||
3. Calls `agent_framework.observability.enable_instrumentation()` so Agent Framework emits its `invoke_agent`, `chat`, `execute_tool`, and `execute_code` spans on those providers.
|
||||
|
||||
Trace linking happens automatically: the Foundry hosting layer's incoming `Responses` request becomes the **parent span**, and every framework / tool span (including the `execute_code` invocation that runs Monty) becomes a child via OpenTelemetry context propagation since both layers share the same global tracer provider. In Application Insights you can click any operation and see the full tree from inbound HTTP all the way down to individual `compute(...)` / `fetch_data(...)` calls inside the Monty sandbox.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
This sample uses `pyproject.toml` + `uv sync` rather than the parent
|
||||
README's `requirements.txt` flow. To run locally:
|
||||
|
||||
1. Install dependencies into a local virtual environment:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. Set the environment variables described in the
|
||||
[parent README](../../README.md#running-the-agent-host-locally) (Foundry
|
||||
project endpoint, model deployment, optional Application Insights), then
|
||||
start the host:
|
||||
|
||||
```bash
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
Refer to the parent README for the shared `azd` / Docker / invocation /
|
||||
deployment guidance.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Depending on how you run the agent host, you can invoke the agent using
|
||||
> `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the
|
||||
> [parent README](../../README.md) for more details. Use this README for
|
||||
> sample queries you can send to the agent.
|
||||
|
||||
Send a POST request to the server with a JSON body containing an `"input"`
|
||||
field. Try queries that benefit from combining Python with multiple tool calls:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Fetch all users, find the admins, then multiply the count by 7. Use a single execute_code call."}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Compute the total price for one of every product in the products table. Use execute_code."}'
|
||||
```
|
||||
|
||||
The model should respond with one `execute_code` call whose code looks like:
|
||||
|
||||
```python
|
||||
users = await fetch_data(table="users")
|
||||
admins = [u for u in users if u["role"] == "admin"]
|
||||
result = await compute(operation="multiply", a=len(admins), b=7)
|
||||
print(result)
|
||||
```
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
To host the agent on Foundry, follow the instructions in the
|
||||
[Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry)
|
||||
section of the README in the parent directory.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
name: agent-framework-agent-monty-codeact-responses
|
||||
description: >
|
||||
An Agent Framework agent with a Monty-backed CodeAct context provider hosted by Foundry.
|
||||
metadata:
|
||||
tags:
|
||||
- Agent Framework
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- CodeAct
|
||||
- Monty
|
||||
template:
|
||||
name: agent-framework-agent-monty-codeact-responses
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: ENABLE_INSTRUMENTATION
|
||||
value: "true"
|
||||
- name: ENABLE_SENSITIVE_DATA
|
||||
value: "true"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
@@ -0,0 +1,15 @@
|
||||
kind: hosted
|
||||
name: agent-framework-agent-monty-codeact-responses
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: ENABLE_INSTRUMENTATION
|
||||
value: "true"
|
||||
- name: ENABLE_SENSITIVE_DATA
|
||||
value: "true"
|
||||
@@ -0,0 +1,136 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_monty import MontyCodeActProvider
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
|
||||
# Load environment variables from .env file (no-op when injected by Foundry).
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _setup_telemetry() -> None:
|
||||
"""Wire Agent Framework spans to the Application Insights resource attached to the Foundry project.
|
||||
|
||||
Foundry-hosted runtimes inject ``APPLICATIONINSIGHTS_CONNECTION_STRING`` automatically;
|
||||
locally you can set it yourself (see README). When the connection string is present we
|
||||
configure Azure Monitor OTel exporters once and then flip the framework's instrumentation
|
||||
flag so it emits ``invoke_agent`` / ``chat`` / ``execute_tool`` spans. The hosting layer's
|
||||
incoming-request span becomes the parent automatically via OpenTelemetry context
|
||||
propagation when both layers share the same global tracer provider.
|
||||
"""
|
||||
connection_string = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING")
|
||||
if not connection_string:
|
||||
logger.info(
|
||||
"APPLICATIONINSIGHTS_CONNECTION_STRING is not set; Agent Framework spans will not "
|
||||
"be exported to Azure Monitor. Set the env var to enable telemetry."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"azure-monitor-opentelemetry is not installed; skipping Azure Monitor setup. "
|
||||
"Install it to export telemetry."
|
||||
)
|
||||
return
|
||||
|
||||
# Configure the global OTel providers (tracer/meter/logger) to export to Azure Monitor.
|
||||
# Idempotent for repeated imports because we only call it from this entry point.
|
||||
configure_azure_monitor(connection_string=connection_string)
|
||||
# Flip the Agent Framework instrumentation flag so its spans are actually emitted on
|
||||
# the now-configured global providers.
|
||||
enable_instrumentation()
|
||||
logger.info("Azure Monitor + Agent Framework instrumentation enabled.")
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def compute(
|
||||
operation: Annotated[
|
||||
Literal["add", "subtract", "multiply", "divide"],
|
||||
Field(description="Math operation: add, subtract, multiply, or divide."),
|
||||
],
|
||||
a: Annotated[float, Field(description="First numeric operand.")],
|
||||
b: Annotated[float, Field(description="Second numeric operand.")],
|
||||
) -> float:
|
||||
"""Perform a math operation used by sandboxed code."""
|
||||
operations = {
|
||||
"add": a + b,
|
||||
"subtract": a - b,
|
||||
"multiply": a * b,
|
||||
"divide": a / b if b else float("inf"),
|
||||
}
|
||||
return operations[operation]
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def fetch_data(
|
||||
table: Annotated[str, Field(description="Name of the simulated table to query.")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch simulated records from a named table."""
|
||||
data: dict[str, list[dict[str, Any]]] = {
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice", "role": "admin"},
|
||||
{"id": 2, "name": "Bob", "role": "user"},
|
||||
{"id": 3, "name": "Charlie", "role": "admin"},
|
||||
],
|
||||
"products": [
|
||||
{"id": 101, "name": "Widget", "price": 9.99},
|
||||
{"id": 102, "name": "Gadget", "price": 19.99},
|
||||
],
|
||||
}
|
||||
return data.get(table, [])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Host a Monty CodeAct agent over the Responses protocol."""
|
||||
# Set up telemetry BEFORE building the client/agent so the framework picks up
|
||||
# the configured tracer provider when it lazily wires instrumentation.
|
||||
_setup_telemetry()
|
||||
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
# MontyCodeActProvider injects a sandboxed `execute_code` tool into every
|
||||
# agent run, plus dynamic instructions describing the registered host tools.
|
||||
# The host tools are hidden from the model - they can only be invoked from
|
||||
# inside the sandbox (`await compute(...)` or `call_tool(...)`).
|
||||
codeact = MontyCodeActProvider(
|
||||
tools=[compute, fetch_data],
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions=(
|
||||
"You are a friendly assistant. Use `execute_code` to combine "
|
||||
"Python control flow with the provided host tools whenever the "
|
||||
"task requires lookups, transformations, or computation."
|
||||
),
|
||||
context_providers=[codeact],
|
||||
# History will be managed by the hosting infrastructure, thus there
|
||||
# is no need to store history by the service. Learn more at:
|
||||
# https://developers.openai.com/api/reference/resources/responses/methods/create
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
server = ResponsesHostServer(agent)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "agent-framework-agent-monty-codeact-responses"
|
||||
version = "0.1.0"
|
||||
description = "Foundry-hosted Agent Framework agent with a Monty-backed CodeAct context provider."
|
||||
requires-python = ">=3.12,<3.14"
|
||||
dependencies = [
|
||||
"agent-framework-foundry",
|
||||
"agent-framework-foundry-hosting",
|
||||
# agent-framework-monty is an alpha (1.0.0a*) release on PyPI.
|
||||
"agent-framework-monty",
|
||||
# Azure Monitor OpenTelemetry exporter; used to send agent telemetry to the
|
||||
# Application Insights instance attached to the Foundry project.
|
||||
"azure-monitor-opentelemetry",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
# `agent-framework-monty` is an alpha package; allow the prerelease resolver
|
||||
# to pick up 1.0.0a* releases from PyPI.
|
||||
prerelease = "allow"
|
||||
|
||||
+16
-16
@@ -90,7 +90,7 @@ Example values below are illustrative. For entries not backed by a single public
|
||||
column names the closest public surface, helper, or package-level initialization point that reads the
|
||||
variable.
|
||||
|
||||
| package | class | env var | example value |
|
||||
| package | class/module | env var | example value |
|
||||
| --- | --- | --- | --- |
|
||||
| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_API_KEY` | `sk-ant-api03-...` |
|
||||
| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL` | `claude-sonnet-4-5-20250929` |
|
||||
@@ -117,21 +117,21 @@ variable.
|
||||
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__SCHEMANAME` | `cr123_agentname` |
|
||||
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__TENANTID` | `11111111-1111-1111-1111-111111111111` |
|
||||
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__AGENTAPPID` | `22222222-2222-2222-2222-222222222222` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_INSTRUMENTATION` | `true` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_SENSITIVE_DATA` | `false` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_CONSOLE_EXPORTERS` | `true` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_NAME` | `sample-agent` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_VERSION` | `1.0.0` |
|
||||
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` |
|
||||
| `agent-framework-core` | `observability` | `ENABLE_INSTRUMENTATION` | `true` |
|
||||
| `agent-framework-core` | `observability` | `ENABLE_SENSITIVE_DATA` | `false` |
|
||||
| `agent-framework-core` | `observability` | `ENABLE_CONSOLE_EXPORTERS` | `true` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_SERVICE_NAME` | `sample-agent` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_SERVICE_VERSION` | `1.0.0` |
|
||||
| `agent-framework-core` | `observability` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` |
|
||||
| `agent-framework-devui` | `DevUI server` | `DEVUI_AUTH_TOKEN` | `my-devui-token` |
|
||||
| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_PROJECT_ENDPOINT` | `https://my-project.services.ai.azure.com/api/projects/my-project` |
|
||||
| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_MODEL` | `gpt-4o` |
|
||||
|
||||
Generated
+89
-2
@@ -50,6 +50,7 @@ members = [
|
||||
"agent-framework-hyperlight",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-monty",
|
||||
"agent-framework-ollama",
|
||||
"agent-framework-openai",
|
||||
"agent-framework-orchestrations",
|
||||
@@ -178,7 +179,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.0.0rc2"
|
||||
source = { editable = "packages/ag-ui" }
|
||||
dependencies = [
|
||||
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -602,7 +603,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -720,6 +721,21 @@ requires-dist = [
|
||||
{ name = "mem0ai", specifier = ">=1.0.0,<2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-monty"
|
||||
version = "1.0.0a260518"
|
||||
source = { editable = "packages/monty" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic-monty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "pydantic-monty", specifier = ">=0,<0.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-ollama"
|
||||
version = "1.0.0b260519"
|
||||
@@ -5737,6 +5753,77 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-monty"
|
||||
version = "0.0.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/f8/431ba0b79d02922811392c4e3d283d6508f7052ceaa1936cc34878703ecc/pydantic_monty-0.0.17.tar.gz", hash = "sha256:9c4904a8fbc63282793f3afd2d180124494c7fc371783f365e5691c9586360af", size = 1007724, upload-time = "2026-04-22T20:13:48.915Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/19/8105bc0b3acb42f6cb48a29669a5e21316bc05e3e9b6fab64cf94b483712/pydantic_monty-0.0.17-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3c3b6c026d8a0437eeb4d6b2d908be75e2715e0555b9a13f076b7e9ba9bbae19", size = 7344730, upload-time = "2026-04-22T20:13:24.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a2/7281cdb37481c4252292b63bebf737c87d0fd463f3174499608607de0907/pydantic_monty-0.0.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c80b4d34437abd209c042f81f8ecea81a097022fb9b01431ab859b877edfbc4d", size = 7334937, upload-time = "2026-04-22T20:15:06.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/68/0bf7c0c627a56d8653b42888a3c1fc33cd33d2532ec456d9358275d7c792/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:beecc1f7e5b10db40d7b2b24a68166a36514289a2402bfef370a7984e90a2ab8", size = 7864543, upload-time = "2026-04-22T20:14:46.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/9b/5a6f006541fd3bdc64b6dfbbaeabfb2244c89a22d7077a1fc92ec497c03e/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64ea7babdcc9fba93089fa52589b6d0549f755e37500f6cf4aeaeb8e56328a3e", size = 7138764, upload-time = "2026-04-22T20:15:30.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/cc/59cca979bd427d166df8c827fba9e794c4a5c08943e225a22adf9854a78f/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a7fe77a191205becb622eaecb075e8bcbbbe4dac20a916d9c58ce6d59a22a8da", size = 7444006, upload-time = "2026-04-22T20:15:23.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/c5/d027170fb33fcbc038febb76dfd2d9047f5194a250ea608e3ed8e5ec28d4/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cdbefc180cc83c8b8415aaf95b9099bb2cb15261f40ebe2c92f13e7d52439a4", size = 7967564, upload-time = "2026-04-22T20:14:57.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/01/ac0d4bc1ff00acfac14b7cb2ee322d08778c206cd57f43da8206a2f6ce78/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:575ce5aa31db18bbbf6275f00e9b0c005ca393bfb73a2f306a577ad490ec2d98", size = 8199021, upload-time = "2026-04-22T20:15:14.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/85/8d0c6e5f127da9ebc0fcda6e411592d12b7606347d67aecd4363df5eed6b/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e252ec54fc4728406045f7be36ca45dbea8e6856df9c6154b1b9821b8952dfa2", size = 7769814, upload-time = "2026-04-22T20:14:55.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/cc/cb4d1b14b039eab00b33a7274f15f81739c3f272e2dfbeb8fb13c6b0c85d/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fba71e5cb49f15a1446ecee142c8cc11f4bd6df4fcb4926465c83181474b2fd4", size = 7317432, upload-time = "2026-04-22T20:14:19.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/16/737c7a023abbcb21848eb4d58f7167d9f4f8cdc46858ce8ed835cc2c137c/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69136647abd56f804987834e37573adcc5c3b3d05013b8b3a2939f44b3bd5199", size = 7767816, upload-time = "2026-04-22T20:13:40.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9f/5302b784f882ae8a8396f29f8c5ab4c16524c173a3d777b94af33858fdf2/pydantic_monty-0.0.17-cp310-cp310-win32.whl", hash = "sha256:d5b3beb6169b59adea10fdefb1e54bfa9a66165404891dfb6fcf16f7749cda3b", size = 7230648, upload-time = "2026-04-22T20:14:27.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/27/8c219f619dad466ec25db365acf88e2a50450dd862e0daff0eb281b6176b/pydantic_monty-0.0.17-cp310-cp310-win_amd64.whl", hash = "sha256:50ed9561b6dd1a1863d4cac81e4eaca64cb10ab541aaab92fcb5996739bb8e7f", size = 8075073, upload-time = "2026-04-22T20:14:17.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/42/ca8e42d9f3318f5c454cf8b168d814ec97c6f2afc38756d4b1b806184f6d/pydantic_monty-0.0.17-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:af890d691f6055491a4e643dd5bf09e07bd7a20ad70038531aada6415ab8794a", size = 7344138, upload-time = "2026-04-22T20:13:29.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c8/cfaf0a56087301d4e88f72cf54ea45a7eebc09c021c85b8864447f1e3755/pydantic_monty-0.0.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f38a69858dfdd2c9474156616d05e25a288e2080aee24152fa40c19ad425f0e", size = 7334903, upload-time = "2026-04-22T20:14:31.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/77/a751a6f73f854aa85fed94cfa5ecab21d7bf218c9fa03c96f9edf470cc4e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bb88264e291cee56770a775f57125538c4713c6d362e89ee63bff506f650a0df", size = 7864258, upload-time = "2026-04-22T20:13:15.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/fe/2eb51eb37e9f712cada64fa8d7df4b63b1f5fc635290147ab158ff0e1ef1/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54c317611454aba8be7ca96aeeea9429f4702a5c4ba89812bea82bed0d8e34fd", size = 7138153, upload-time = "2026-04-22T20:14:22.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/15/835b10cdec3b96b089eef9899df6850b7f84a10225c491698b0ecf8e532a/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9563b5b4933f0f08c0e66ec66aaa4f43f2388bcc04b984e58aab2146dacd3829", size = 7443572, upload-time = "2026-04-22T20:13:17.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/92/aca140923fad8a2821a135cfeaa2fbb3321063bbadaa760424a016bb1ac6/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35f267a501bc1910178a1515fdd3dd927273fbb44e44b8718cb3b33aee79f41b", size = 7967178, upload-time = "2026-04-22T20:14:06.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/7c4ff1e3fe2e82a4745decfca67b54a7a61cd306875e32d8e41c5192c69e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35b000c52755f25f322ea7c4d079f09aa60635ffe24a6463899e423066a41bf3", size = 8198241, upload-time = "2026-04-22T20:15:21.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/0b/702db7b753b96ebc6713e7cbdfaecdb471df3e3cb0f0f6e828620a743b78/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61b517776ad13aa4580b1dd89188b18296ceeaf88256423563bbc99e804fd83f", size = 7768859, upload-time = "2026-04-22T20:13:20.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/8d16e0cc0c36d1444f25d57da68dd22216bf0961c457a482429cec32141b/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5da5362ef25665a23a3b13024497719f65cafa61d696cac76429f84701bee2e2", size = 7316674, upload-time = "2026-04-22T20:14:52.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/4d/d47ae703d402e45475333c4bf11b117c8068305f00c1363dbaea13d0fd09/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7e655b6ddd552c02b751f1d57fc291fbd5654ff8b166a8bd634857879160d0b7", size = 7767515, upload-time = "2026-04-22T20:15:16.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9b/e17fb50d0df5cf9908f8fffa25c5909ed0eb92ca102ded06f7a6d6133e78/pydantic_monty-0.0.17-cp311-cp311-win32.whl", hash = "sha256:ea8b3ae8c42d572cefad841d3bda63cc458d9de2361cb9172914250e6dbe2c75", size = 7230347, upload-time = "2026-04-22T20:14:08.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/82/d3119f59652d04bcf69d671ddbd38464d5775fbc738a258d3c8f7800e29d/pydantic_monty-0.0.17-cp311-cp311-win_amd64.whl", hash = "sha256:3293c2f7524bfc7c3d8c794f1c1dc1eb4cf9c65a5e222061e2218ced85f3f6df", size = 8074183, upload-time = "2026-04-22T20:14:50.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/31/95827babdb35149f076c5d191b6b1e7a7c58f4bc72432f905e02e4e3231e/pydantic_monty-0.0.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27c2254fa7a7b05e969f79578889230d293c62e0b1ee28371ec4f3c54b14426a", size = 7342248, upload-time = "2026-04-22T20:15:18.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/67/ca9cfc07cd445d22def53e9db86912f9ae3e11ef772ce41c2ff41a47eac5/pydantic_monty-0.0.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:445cc471ce6f5a88ef06741b7ebc7002a2253d182f55a2f47094d4adedaaf497", size = 7311255, upload-time = "2026-04-22T20:15:27.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/96/abc9c4972d91a9673435b84e12b99d038e42d1f99648fc9e5f242e09d00e/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:39121038405911f59da7bf61164251f59bad3fb1b0cd28f43c42c3949eee2c8a", size = 7868109, upload-time = "2026-04-22T20:15:04.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/82/9e4d55529bb99d882b9277a721762537a8bc1345ab1d052bb614a88bd15b/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dafc8ffe57c257002f623afdb7d0e41f73de850179ebd90b42611e4f2b6f9884", size = 7139709, upload-time = "2026-04-22T20:15:25.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/97/f1af6acefb7bb38d73934d6853998bbd327de7418b811372519080d9fd84/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea00838ef8f37dcd8085defcbdfe89fdd05297a6533f1d3f4cad857d13cedc7b", size = 7450444, upload-time = "2026-04-22T20:13:37.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/91/af92ef409e1c065345cf1451bbcf19e00f70a250b8372ec65143ca9a9238/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683d18089acf14d0de293245b9e37c7f0ec64e6d266f6773144211931aa3ec97", size = 7967525, upload-time = "2026-04-22T20:13:42.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1f/23ecd6e268ef24ce6b0fe4a1e76a314990d2e923ac5791e29d657418243d/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1829993dd50cf497cbed66ea9f6c8ff7d157d22592a05c7399f92fb8a549e3c", size = 8199124, upload-time = "2026-04-22T20:15:00.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/2a/36b694ea0c7e202250a81a57faf00f218738da6c5d070c752f2d81cd34ce/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a7869e3f41a54cc588096c52a8a4de25ecd81e75c867ea4164b14ea1ae1a57f", size = 7739623, upload-time = "2026-04-22T20:14:37.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e5/090357d7bc0f0751d1afbb71330695fa26554699c88ba56ecaad91657088/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9c17663e2c6f07aec5bc54cd7e39a9e20f250a97a9081e1b2b932eb00d0afc5", size = 7317755, upload-time = "2026-04-22T20:14:48.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/63/67200070cf33325ecfda81d4aee3bf312250ce80bd73058103e04e0f3587/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5d2cf98afe2fb124f6ade91d9663d54277478cad417164f83df1854a41ef450c", size = 7769158, upload-time = "2026-04-22T20:14:39.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ce/9ecfbc2f45406cfb247fafdea4f4a8412db3e559a22c4385eb15266ba2c1/pydantic_monty-0.0.17-cp312-cp312-win32.whl", hash = "sha256:b2185cc4effbbd6793eed4e0f0bcb6a3dbfbb3289ea4d47888708813f0a3dd47", size = 7227917, upload-time = "2026-04-22T20:14:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/80/9be3bef8273817ccc17da25c3ce4ff5d5d45e5629c17eacc90cdef073821/pydantic_monty-0.0.17-cp312-cp312-win_amd64.whl", hash = "sha256:7833daed757ec9b09b627cc3577a4a76b114c5148f779531d7cfdb1095bcf0a9", size = 8043469, upload-time = "2026-04-22T20:13:22.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/44/0e106b8b27eb93b66e4f3d279486464e05ba5ee31088848e58b5f506f879/pydantic_monty-0.0.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0cdac8c3c16477596bc96ee1cec4f2fbaccd089e2daa1e7b9f227cc89f97cb1", size = 7341507, upload-time = "2026-04-22T20:14:41.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/88/a0315fa08e62e2d1ef00c03d8202d7bef3f1f71543bebfb916fea265c0a2/pydantic_monty-0.0.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37290d6a1c35aba5cfb8b490bb31c0d822e8ddca8f3ca9ea068e30930d80dd1e", size = 7311916, upload-time = "2026-04-22T20:13:34.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e5/e4da6acb408594cbfbfb8dd3c0491b9b2ee54e9183e7ebc5f584baa07af9/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:49f252b2fb918686d3e8f76cb30245e782d1560a7fa68dbc0f6940d83c12bd41", size = 7867465, upload-time = "2026-04-22T20:13:31.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/d4/64c2f8eb708a743b0944ea8f71dfd51bc655285b4be28d55577dafbb29fa/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2254d25c34463d67069f5f1567157bde9311654cd5371f8933b8ea9815bfa26a", size = 7139262, upload-time = "2026-04-22T20:14:10.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/67/5d766f9cd304e871a5dfe5f0a85eaa533538ef07e9b2858fdf9f37f83694/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0688a1fa5dc045ac7b7e996d7269b94f74f116d7b1e352c7b5bb5ad53d4fe03", size = 7450119, upload-time = "2026-04-22T20:13:44.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/98/fa16779021d93edb19807e87cdba56bbec6adfad21f10b41a212572ce513/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b35121ac555ed201405c69d531e4cb916da6984b8cd2e15c8a319117349faf6", size = 7967398, upload-time = "2026-04-22T20:13:55.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/64/287a42720bc9e975ab5b52625aa9fc6bcef8298dd821022cc45c6ee1808d/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c97dc44af25d4392b474902fc40f78f09fe8f44ba791364670334fe12abc077", size = 8198835, upload-time = "2026-04-22T20:15:02.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/37/03edb1fd582b79b2b462afc3fea5e1c8fea73afefc4870dc35fc3c7c492e/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ed2fb365ef9ca921de9a17786ecfa2efe06e65678e6ca57be51658a2a880f31", size = 7739241, upload-time = "2026-04-22T20:13:46.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0b/b765c9ca2ae27def1caa07345aba073ae1239fc2d9cca7a375f3dc2195f7/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5bf9f07b38dd12747e3c95b169a5afb3e2e9107622e01e548246c84d19a69c99", size = 7316719, upload-time = "2026-04-22T20:14:13.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/3b/64fe872cd575ab5262e1ba2959554ead198c939cfaa425f7f8e9b1ad2694/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e9bafd4b5acbc0a8e12ee8403a3ce37281c3b0fa5909d3f412bee76c69003c", size = 7769150, upload-time = "2026-04-22T20:13:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/b4/b6a0bb41f39bac2e11e6a2fd42ca0886893fafa6344fb17c3f0a94e22e83/pydantic_monty-0.0.17-cp313-cp313-win32.whl", hash = "sha256:1c239ae3e610d3f39cd1609285209a4e2d046b465ac1bfed0d4374c615eed0fa", size = 7227705, upload-time = "2026-04-22T20:15:12.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e7/d8cd62f537f7ab17714ee19ea221a0e341dab407215376ab1c41d79794c9/pydantic_monty-0.0.17-cp313-cp313-win_amd64.whl", hash = "sha256:1886c3590b02f359ae991f1e76691064f167330eda4fbf22762127ce17d0eb48", size = 8043469, upload-time = "2026-04-22T20:14:24.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/c0/8354baf835e1a04c4b9e11d253f82df7d625a9305e6a23a177fb895b1484/pydantic_monty-0.0.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a166bd04d1996f0d144fbb5e1391cd1c0fbdacd4fa3b689dd48931388679fe98", size = 7341303, upload-time = "2026-04-22T20:14:35.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ac/d58221b5e17915421ca00bb08b805ac121b6accb194785d5422da4a2f5fc/pydantic_monty-0.0.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d82f319e3fd79707a7b81bbb68596509d14ac73502d2f0daf4ab5d281efdfbdc", size = 7318912, upload-time = "2026-04-22T20:13:59.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/3f/8eeb8f652f6cd6e06a737aa9f00de2949e37a669b31756bc51b6182457b4/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f38b9875f7ff56fe69538b60b2ecbdcbe2b8b7780407ceb05fe0ee1414bf8d19", size = 7867027, upload-time = "2026-04-22T20:13:51.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ba/ec6620c27c8b4cada6ce53378c52c245e238e50a20b968cafdc1b8573c4e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74a778bb5a4dcdbc85b3b9002f9a72a43fb6ffd88635bfdd502e8d3053008337", size = 7137542, upload-time = "2026-04-22T20:13:35.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/78/5419785630511b54b15cfb094871bcb53ec9025ba8d91bd7ab5b22b6c98f/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e2ab074a65738e9c1b4be9a432e7ca1e9987a6706018dc7a5af6d4ce7cecdf3", size = 7450222, upload-time = "2026-04-22T20:13:53.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/04/cec11fa96a47034da3c21af53e73f43d2270f5ce96cd710809859fe9c0b2/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00fd1cd28b4200c9ccd02629868486b366af1bfe1f0584d4c9e513b7a941a868", size = 7967405, upload-time = "2026-04-22T20:14:03.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e3/f2be0fb975100b6936ca36a8410098f10fab3b26729c0b0d1de2fac59ff3/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ea6684555bfd00cbe9d2df3e73caada3d82200c168c63cc475197b55b88401", size = 8199028, upload-time = "2026-04-22T20:13:26.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/43/358bdaa9c50d21fe4a25a71d43ce9af2d6796616fa47aca84f807433564e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f804a03a3bbd0cf0ade1d4ce11b50ca6858e9c4440b27c746faf1d3c0a272954", size = 7749903, upload-time = "2026-04-22T20:15:09.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/da/8bcd0a78abf13edceca36aaf5c180fad963e4c2e042fd7247b9e96048306/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:b5476e6c08b86b0bea554b97ce9b142aac1177447f2d3c5751864b27991fd1b1", size = 7312704, upload-time = "2026-04-22T20:14:33.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/49/5de8bb7f8b82c3ebb8f2485e0b7a40055b072193039d95dcb5d35fcba72c/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b5fcdcca45439844bee268686f37226dbf5803ec7a5945f5536c41419f151dac", size = 7768902, upload-time = "2026-04-22T20:14:29.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7c/500a002f1a52f17c8b8a989875da3e1590c18ce95c89856aa967e2348a36/pydantic_monty-0.0.17-cp314-cp314-win32.whl", hash = "sha256:4dd3e6e80a415e7272f7a7583a4f8e045096653f6074e181117eb61fc8fe3b45", size = 7227049, upload-time = "2026-04-22T20:14:01.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/20/b8f552f2a863778f49ebb708f18aaf0bfb275480a48965786e06efacf1c4/pydantic_monty-0.0.17-cp314-cp314-win_amd64.whl", hash = "sha256:36a8090a628e8cf91df8f66c721a71050ac8f48473d4992b9afbd9585941a647", size = 8062999, upload-time = "2026-04-22T20:14:44.006Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.14.1"
|
||||
|
||||
Reference in New Issue
Block a user