Compare commits

..

3 Commits

Author SHA1 Message Date
Chris Gillum 4db9ee62de Narrow external feature package paths
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6181dcf9-857b-43ea-9fd2-fcd6b175ffdd
2026-07-31 14:51:49 -07:00
Chris Gillum 4a391672f7 Fix feature registry validation after extraction
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6181dcf9-857b-43ea-9fd2-fcd6b175ffdd
2026-07-31 14:41:51 -07:00
Chris Gillum 3d281d0ced Extract Durable Task and Azure Functions integrations
Remove the migrated implementations, samples, tests, documentation, and repository wiring now owned by microsoft/agent-framework-durable-extension. Preserve Python compatibility through the agent_framework.azure shim and agent-framework-core[all], and leave customer-facing redirects to the new repository.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6181dcf9-857b-43ea-9fd2-fcd6b175ffdd
2026-07-31 13:53:01 -07:00
289 changed files with 3018 additions and 22996 deletions
+1 -2
View File
@@ -2,7 +2,6 @@ dirs:
- .
excludedFiles:
- ./python/CHANGELOG.md
- "**/SKILL.md"
ignorePatterns:
- pattern: "/github/"
- pattern: "./actions"
@@ -27,7 +26,7 @@ ignorePatterns:
- pattern: "https:\/\/dotnet.microsoft.com"
- pattern: "https://github.com/Rel1cx/eslint-react"
# excludedDirs:
# Folders which include links to localhost, since it's not ignored with regular expressions
# Folders which include links to localhost, since it's not ignored with regular expressions
baseUrl: https://github.com/microsoft/agent-framework/
aliveStatusCodes:
- 200
+1 -1
View File
@@ -17,7 +17,7 @@ runs:
using: "composite"
steps:
- name: Set up uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version-file: "python/pyproject.toml"
enable-cache: true
@@ -1,19 +0,0 @@
name: Save Sample Playbooks
description: >
Save the cached sample-validation playbooks. Split out from
sample-validation-setup (which only restores) so the save runs even when the
validation step fails. Combining restore+save via actions/cache would skip the
save on a failing job (post-if: success()), so freshly authored playbooks for
samples that failed validation would never persist. Invoke this with
'if: not-cancelled' after the validation step in each job.
runs:
using: "composite"
steps:
- name: Save sample playbooks cache
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
# Must match the restore path/key in sample-validation-setup/action.yml and the
# sample_validation --playbooks-dir default (samples/sample_validation/playbooks).
path: python/samples/sample_validation/playbooks/
key: sample-playbooks-${{ github.job }}-${{ github.run_id }}
@@ -36,31 +36,15 @@ runs:
shell: bash
run: copilot --version && copilot -p "What can you do in one sentence?"
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ inputs.python-version }}
os: ${{ inputs.os }}
- name: Restore sample playbooks
# Restore-only. The matching save is a separate step in each job that runs with
# `if: ${{ !cancelled() }}` (see .github/actions/sample-validation-save-playbooks).
# A combined actions/cache would skip its post-job save on a failing job
# (post-if: success()), so playbooks authored for samples that failed validation
# would never persist. Keyed per job so each validate-* job keeps its own playbooks.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
# Must match the sample_validation --playbooks-dir default, which resolves to
# samples/sample_validation/playbooks (see python/scripts/sample_validation/__main__.py).
# If a job overrides --playbooks-dir, update this path to match.
path: python/samples/sample_validation/playbooks/
key: sample-playbooks-${{ github.job }}-${{ github.run_id }}
restore-keys: |
sample-playbooks-${{ github.job }}-
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
subscription-id: ${{ inputs.azure-subscription-id }}
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ inputs.python-version }}
os: ${{ inputs.os }}
+3 -3
View File
@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -64,6 +64,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -85,7 +85,7 @@ jobs:
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout GitHub automation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
sparse-checkout: |
@@ -165,7 +165,7 @@ jobs:
steps:
# Safe checkout: base repo only, not the untrusted PR head.
- name: Checkout target repo base
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
fetch-depth: 0
@@ -174,7 +174,7 @@ jobs:
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
- name: Checkout DevFlow
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -189,7 +189,7 @@ jobs:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.x"
enable-cache: true
+5 -5
View File
@@ -40,7 +40,7 @@ jobs:
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
@@ -103,7 +103,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -177,7 +177,7 @@ jobs:
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -355,7 +355,7 @@ jobs:
env:
configuration: Release
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -488,7 +488,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
@@ -43,7 +43,7 @@ jobs:
environment: integration
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
environment: 'integration'
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -25,7 +25,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
@@ -42,7 +42,7 @@ jobs:
python-changes: ${{ steps.detect-changes.outputs.python }}
steps:
- name: Check out trusted workflow helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.sha }}
persist-credentials: false
+4 -4
View File
@@ -68,7 +68,7 @@ jobs:
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: |
.github/actions/github-app-token
@@ -135,7 +135,7 @@ jobs:
steps:
# Safe checkout: base repo only.
- name: Checkout target repo base
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
@@ -143,7 +143,7 @@ jobs:
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -158,7 +158,7 @@ jobs:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.x"
enable-cache: true
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
issues: write
steps:
- name: Checkout GitHub automation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: |
.github/actions/github-app-token
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
steps:
- name: Checkout scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
@@ -82,7 +82,7 @@ jobs:
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
steps:
- name: Checkout scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04
# check out the latest version of the code
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -42,7 +42,7 @@ jobs:
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/prek
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
@@ -68,7 +68,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -97,7 +97,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -128,7 +128,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -25,7 +25,7 @@ jobs:
# installability starts differing across supported Python versions.
UV_PYTHON: "3.13"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
+2 -2
View File
@@ -24,9 +24,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version-file: "python/pyproject.toml"
enable-cache: true
+11 -12
View File
@@ -48,7 +48,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -81,7 +81,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -127,7 +127,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -178,7 +178,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -192,7 +192,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -232,12 +232,11 @@ jobs:
fallback_url: ${{ env.LOCAL_MCP_URL }}
- name: Prefer local MCP URL when available
run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV"
- name: Test with pytest (Anthropic, Hyperlight, Mistral, Ollama, MCP integration)
- name: Test with pytest (Anthropic, Hyperlight, Ollama, MCP integration)
run: >
uv run pytest --import-mode=importlib
packages/anthropic/tests
packages/hyperlight/tests
packages/mistral/tests
packages/ollama/tests
packages/core/tests/core/test_mcp.py
packages/hosting-mcp/tests
@@ -297,7 +296,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -347,7 +346,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -402,7 +401,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -449,7 +448,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -497,7 +496,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
@@ -63,7 +63,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
+12 -14
View File
@@ -41,7 +41,7 @@ jobs:
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
@@ -67,7 +67,6 @@ jobs:
misc:
- 'python/packages/anthropic/**'
- 'python/packages/hyperlight/**'
- 'python/packages/mistral/**'
- 'python/packages/ollama/**'
- 'python/packages/core/agent_framework/_mcp.py'
- 'python/packages/core/tests/core/test_mcp.py'
@@ -107,7 +106,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -154,7 +153,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -215,7 +214,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -285,7 +284,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -296,7 +295,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -336,12 +335,11 @@ jobs:
fallback_url: ${{ env.LOCAL_MCP_URL }}
- name: Prefer local MCP URL when available
run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV"
- name: Test with pytest (Anthropic, Hyperlight, Mistral, Ollama, MCP integration)
- name: Test with pytest (Anthropic, Hyperlight, Ollama, MCP integration)
run: >
uv run pytest --import-mode=importlib
packages/anthropic/tests
packages/hyperlight/tests
packages/mistral/tests
packages/ollama/tests
packages/core/tests/core/test_mcp.py
packages/hosting-mcp/tests
@@ -413,7 +411,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -474,7 +472,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -545,7 +543,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -606,7 +604,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -660,7 +658,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+66 -276
View File
@@ -8,11 +8,8 @@ on:
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
GITHUB_TOKEN: ${{ github.token }}
GITHUB_COPILOT_MODEL: auto
permissions:
copilot-requests: write
contents: read
id-token: write
@@ -23,13 +20,13 @@ jobs:
environment: integration
env:
# Required configuration for get-started samples
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -48,10 +45,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -61,13 +54,12 @@ jobs:
validate-02-agents:
name: Validate 02-agents
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
# Foundry configuration
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
@@ -75,19 +67,19 @@ jobs:
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
# GitHub MCP
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
OPENAI_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
# Observability
ENABLE_INSTRUMENTATION: "true"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -113,11 +105,7 @@ jobs:
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers harness tools --save-report --report-name 02-agents
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -126,110 +114,20 @@ jobs:
name: validation-report-02-agents
path: python/samples/sample_validation/reports/
validate-02-agents-harness:
name: Validate 02-agents/harness
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
# Optional: enables the Foundry memory path in harness samples
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
FOUNDRY_MEMORY_STORE: ${{ vars.FOUNDRY_MEMORY_STORE || '' }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
- name: Create .env for samples
run: |
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
echo "FOUNDRY_EMBEDDING_MODEL=$FOUNDRY_EMBEDDING_MODEL" >> .env
echo "FOUNDRY_MEMORY_STORE=$FOUNDRY_MEMORY_STORE" >> .env
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/harness --save-report --report-name 02-agents-harness
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-harness
path: python/samples/sample_validation/reports/
validate-02-agents-tools:
name: Validate 02-agents/tools
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
- name: Create .env for samples
run: |
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/tools --save-report --report-name 02-agents-tools
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-tools
path: python/samples/sample_validation/reports/
validate-02-agents-openai:
name: Validate 02-agents/providers/openai
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -250,10 +148,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -263,7 +157,6 @@ jobs:
validate-02-agents-azure:
name: Validate 02-agents/providers/azure
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
@@ -274,7 +167,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -294,10 +187,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure --save-report --report-name 02-agents-azure
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -307,7 +196,6 @@ jobs:
validate-02-agents-anthropic:
name: Validate 02-agents/providers/anthropic
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
@@ -317,7 +205,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -336,10 +224,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -349,14 +233,20 @@ jobs:
validate-02-agents-github-copilot:
name: Validate 02-agents/providers/github_copilot
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
permissions:
copilot-requests: write
contents: read
id-token: write
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_COPILOT_MODEL: claude-opus-4.6
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -370,10 +260,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -392,7 +278,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -406,10 +292,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -428,7 +310,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -442,10 +324,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -455,19 +333,19 @@ jobs:
validate-02-agents-foundry:
name: Validate 02-agents/providers/foundry
if: false # Temporarily disabled - to free up Copilot quota for other jobs
if: false # Temporarily disabled - provider folder also contains the local Foundry sample
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME || '' }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION || '' }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -488,10 +366,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry --save-report --report-name 02-agents-foundry
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -513,7 +387,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -534,10 +408,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -553,7 +423,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -567,10 +437,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -580,17 +446,16 @@ jobs:
validate-03-workflows:
name: Validate 03-workflows
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -609,10 +474,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -620,68 +481,21 @@ jobs:
name: validation-report-03-workflows
path: python/samples/sample_validation/reports/
validate-04-hosting-foundry-hosted-agents:
name: Validate 04-hosting (foundry-hosted-agents)
validate-04-hosting:
name: Validate 04-hosting
if: false # Temporarily disabled because of sample complexity
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
# Foundry hosted agent configuration
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ID: ${{ vars.FOUNDRY_PROJECT_ID }}
AZURE_CONTAINER_REGISTRY_ENDPOINT: ${{ vars.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
TOOLBOX_ENDPOINT: ${{ vars.TOOLBOX_ENDPOINT }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_NAME }}
MEMORY_STORE_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_MEMORY_STORE }}
AZURE_SEARCH_ENDPOINT: ${{ vars.AZURE_SEARCH_ENDPOINT }}
AZURE_SEARCH_INDEX_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_SEARCH_INDEX_NAME }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
- name: Run sample validation
# Maximum parallel workers is set to 1 because all samples use the same port
run: |
cd scripts && uv run python -m sample_validation --subdir 04-hosting/foundry-hosted-agents --save-report --report-name 04-hosting-foundry-hosted-agents --max-parallel-workers 1
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-04-hosting-foundry-hosted-agents
path: python/samples/sample_validation/reports/
validate-04-hosting-other:
name: Validate 04-hosting (other)
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# A2A configuration
A2A_AGENT_HOST: http://localhost:5001/
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -693,17 +507,13 @@ jobs:
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir 04-hosting --exclude foundry-hosted-agents --save-report --report-name 04-hosting-other
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-04-hosting-other
name: validation-report-04-hosting
path: python/samples/sample_validation/reports/
validate-05-end-to-end:
@@ -712,8 +522,8 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
@@ -728,7 +538,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -742,10 +552,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -758,21 +564,21 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
OPENAI_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -792,16 +598,9 @@ jobs:
echo "OPENAI_CHAT_COMPLETION_MODEL=$OPENAI_CHAT_COMPLETION_MODEL" >> .env
echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env
- name: Pre-install AutoGen dependencies for migration samples
run: uv pip install "autogen-agentchat" "autogen-ext[openai]"
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration --agent-timeout 600
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -812,25 +611,23 @@ jobs:
validate-semantic-kernel-migration:
name: Validate semantic-kernel-migration
if: false # Temporarily disabled - to free up Copilot quota for other jobs
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration for AF
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration for SK
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
# OpenAI key
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
OPENAI_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
# OpenAI configuration for SK
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
# Copilot Studio
COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }}
COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }}
@@ -840,7 +637,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -868,10 +665,6 @@ jobs:
run: |
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
- name: Save sample playbooks
if: ${{ !cancelled() }}
uses: ./.github/actions/sample-validation-save-playbooks
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
@@ -886,8 +679,6 @@ jobs:
needs:
- validate-01-get-started
- validate-02-agents
- validate-02-agents-harness
- validate-02-agents-tools
- validate-02-agents-openai
- validate-02-agents-azure
- validate-02-agents-anthropic
@@ -898,13 +689,12 @@ jobs:
- validate-02-agents-copilotstudio
- validate-02-agents-custom
- validate-03-workflows
- validate-04-hosting-foundry-hosted-agents
- validate-04-hosting-other
- validate-04-hosting
- validate-05-end-to-end
- validate-autogen-migration
- validate-semantic-kernel-migration
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download all validation reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -20,7 +20,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download coverage report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
env:
UV_PYTHON: "3.11"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Get GitHub automation token
id: github-auth
@@ -172,7 +172,7 @@ parsing a structured payload into a typed record), without coupling the holder t
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
`AgentSessionStore` key or a workflow checkpoint session id.
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
`UseClaimsBasedAgentIsolation(...)`), so the session namespace is scoped per principal.
`UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal.
- Persist session/checkpoint state only after the run or stream has completed.
## E2E Code Samples
+3 -11
View File
@@ -353,12 +353,6 @@ that manually replay messages own the equivalent rule: do not resend an approval
- `function_approval_request` and `function_approval_response` are control-plane contents, not durable model
transcript items.
- A current hosted approval response must be sent once on the immediate resume request.
- AG-UI removes a local approval response from its request and snapshot replay when a terminal result belongs to an
already-consumed occurrence, including result-before-response replay. A client-authored result in the occurrence
that is still registered as pending does not prove completion: AG-UI removes that result, keeps the validated
response for local execution, and leaves hosted approval responses as provider protocol data.
- Hosted AG-UI approval interrupts expose an accept/reject decision only; argument edits are rejected because the
hosted provider executes the server-owned request rather than client-edited arguments.
- A server-issued approval request must not be replayed inline during service-side continuation.
- History providers may retain approval control contents in their backing store for audit, but base history replay
filters them before later model calls.
@@ -370,8 +364,7 @@ that manually replay messages own the equivalent rule: do not resend an approval
- Model-bound history contains one function call/result pair per completed logical occurrence.
- Append-only history must not replay stale approval request/response wrappers to the model.
- Framework-managed and service-managed continuation must preserve the same logical call/result transcript.
- A trusted terminal result consumes the corresponding approval authority in explicit stateless replay; a result in a
server-registered pending occurrence cannot consume that authority before local execution.
- A terminal result consumes the corresponding approval authority in explicit stateless replay.
## Scenario-to-test matrix
@@ -446,7 +439,6 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Hosted server boundary | Standing approval does not cross `server_label`. | `test_tool_approval_middleware_standing_rules_include_hosted_server_boundary` |
| Argument-scoped rule | Exact arguments are required; empty arguments are not tool-wide. | `test_tool_approval_middleware_always_approve_tool_with_arguments_rule`, `test_tool_approval_middleware_empty_arguments_rule_is_not_tool_wide` |
| Provider-injected approval tool | A tool added during `before_run` defers to in-run resolution, executes once, and emits one result. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes` |
| AG-UI provider boundary | Completed local approval controls from AG-UI request and snapshot replay are absent from raw chat-client input while deferred and hosted approvals keep their respective in-run/provider paths. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_does_not_forward_resolved_local_approval_control_to_chat_client`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_canonical_resume_preserves_hosted_approval_for_provider`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_removes_duplicate_completed_controls`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_pairs_reused_call_ids_by_occurrence`, `packages/ag-ui/tests/ag_ui/test_run.py::test_canonical_hosted_approval_resume_rejects_edited_arguments_without_mutating_pending` |
### Errors, control flow, and limits
@@ -473,8 +465,8 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` |
| Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` |
| Hosted per-service-call persistence | A host-managed transcript remains available throughout a local function-call loop without being persisted into the framework session and replayed on the next hosted request. | `packages/foundry_hosting/tests/test_responses.py::TestAgentSessionPersistence::test_per_service_call_persistence_preserves_function_loop_history` |
| Service-side approval decision | Stored hosted request is skipped; the current approved or rejected hosted response is sent, while local approval controls are omitted from provider input. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage`, `test_prepare_messages_drops_local_approval_controls` |
| OpenAI approval serialization | Hosted approval id and decision serialize to `mcp_approval_response`; local approvals remain in-process. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
| Service-side approval decision | Stored request is skipped; current approved or rejected response is sent. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage` |
| OpenAI approval serialization | Approval id and decision serialize to `mcp_approval_response`. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
| OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` |
| Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
-3
View File
@@ -26,9 +26,6 @@
<ItemGroup Condition="'$(InjectSharedDiagnosticIds)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\DiagnosticIds\*.cs" LinkBase="Shared\DiagnosticIds" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedUsage)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Usage\*.cs" LinkBase="Shared\Usage" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedRedaction)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Redaction\*.cs" LinkBase="Shared\Redaction" />
</ItemGroup>
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.17.0</VersionPrefix>
<VersionPrefix>1.16.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260804</DateSuffix>
<DateSuffix>260730</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.17.0</GitTag>
<GitTag>1.16.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
+1 -1
View File
@@ -228,7 +228,7 @@ dotnet run
`ConversationId` keeps request/response continuity. It is not proof that the caller owns that conversation. In multi-user deployments, authenticate each AG-UI request and authorize conversation access using your application's real boundary, such as the authenticated user, tenant, or workspace.
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedAgentIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedSessionIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
## Troubleshooting
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -17,9 +17,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -28,9 +28,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -18,9 +18,9 @@ builder.Services.AddAGUIServer();
builder.WebHost.UseUrls("http://localhost:8888");
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -38,8 +38,8 @@
<ItemGroup>
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
Microsoft Agent Framework adapter. -->
<PackageReference Include="AgentMemory" Version="1.3.0" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.3.0" />
<PackageReference Include="AgentMemory" Version="1.2.0" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.2.0" />
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
@@ -59,10 +59,6 @@ static async Task RunWorkflowAsync(Workflow workflow)
}
await run.TrySendMessageAsync(userInput);
// Agents are wrapped as executors that cache incoming messages and only run when they receive a TurnToken,
// so the turn must be triggered explicitly after sending the user input.
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
string? speakingAgent = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token))
{
@@ -101,10 +101,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
@@ -20,9 +20,9 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.T
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -50,9 +50,9 @@ var agent = new AzureOpenAIClient(
]);
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
@@ -13,9 +13,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -28,10 +28,10 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
@@ -157,10 +157,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
@@ -725,7 +725,7 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable
? HttpCompletionOption.ResponseHeadersRead
: HttpCompletionOption.ResponseContentRead;
using var response = await client.SendAsync( // CodeQL [SM03781] False positive: ValidateProxyTarget confirms the target host, scheme, and port match the configured backend, so the user-supplied path and query cannot change the destination.
using var response = await client.SendAsync(
request, completionOption, context.RequestAborted).ConfigureAwait(false);
if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream")
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Threading;
@@ -34,16 +33,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
/// </summary>
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();
/// <summary>Identifies the handler as the source of chat history messages it passes as input.</summary>
private const string HistorySourceId = "Microsoft.Agents.AI.Foundry.Hosting.AgentFrameworkResponseHandler";
/// <summary>
/// The session type a hosted workflow runs with. It is internal to <c>Microsoft.Agents.AI.Workflows</c>,
/// so it is recognised by name: taking a reference to it would mean opening that package's internals,
/// which cannot be done here because both packages compile the same shared source files.
/// </summary>
private const string WorkflowSessionTypeName = "WorkflowSession";
/// <summary>
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
/// that resolves agents from keyed DI services.
@@ -123,21 +112,16 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared
// by design — per-user isolation applies only when a user identity was resolved (hosted).
var conversationId = request.GetConversationId();
var agentSessionId = HostedConversationKey.Resolve(
var sessionConversationId = HostedConversationKey.Resolve(
conversationId, request.PreviousResponseId, context.ResponseId);
var agentOptions = agent.GetService<ChatClientAgentOptions>();
var chatClientAgent = agent.GetService<ChatClientAgent>();
// Load an existing session when there is a conversation key. The store returns null when
// nothing is persisted for it, which is the authoritative "this is a resume" signal: a
// non-null result means a prior turn saved this session. Whether loaded or created, the
// handler owns creating a fresh session when none exists, so the resume signal does not
// depend on inspecting the session for state the handler itself also writes to.
AgentSession? sessionLoadedFromStore = !string.IsNullOrWhiteSpace(agentSessionId)
? await sessionStore.GetSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false)
: null;
AgentSession? session = sessionLoadedFromStore ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId)
? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false)
: chatClientAgent is not null
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// Capture the platform per-request call id (x-agent-foundry-call-id, protocol 2.0.0 only).
// It is re-applied to the ambient HostedCallContext immediately before each outbound egress
@@ -169,19 +153,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
}
}
// A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider. A
// conversation id on the session means the service behind the agent's chat client is recording
// a second one, which nothing here reads and which no one reconciles with the first. Refuse
// before any work is done, as a plain bad request rather than a failure part way through.
if (session is ChatClientAgentSession { ConversationId: not null })
{
throw new ResponsesApiException(
new Error(
"service_managed_chat_history_not_supported",
"Chat history is managed by the hosted agent service, therefore using a ChatClientAgent with its own service storage is not supported. Configure the agent's chat client so the underlying service does not store responses."),
400);
}
// 3. Create the SDK event stream builder
var stream = new ResponseEventStream(context, request);
@@ -192,17 +163,18 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// 4. Convert input: history + current input → ChatMessage[]
var messages = new List<ChatMessage>();
// Add the chat history to the request. Workflow sessions accumulate previous turns and must not
// get the full history again; their types are internal, hence the check on the type name.
if (sessionLoadedFromStore is null
|| !string.Equals(sessionLoadedFromStore.GetType().Name, WorkflowSessionTypeName, StringComparison.Ordinal))
// Load conversation history only for fresh sessions. When a session already exists
// (e.g. resuming a workflow paused at an external-input port), the workflow's
// checkpointed state already contains the prior turns' messages — replaying history
// would re-drive completed actions and break HITL resume semantics.
var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId))
&& session?.StateBag?.Count > 0;
if (!isResume)
{
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter
.ConvertOutputItemsToMessages(history, session?.StateBag)
.Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, HistorySourceId)));
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
}
}
@@ -219,16 +191,9 @@ public class AgentFrameworkResponseHandler : ResponseHandler
}
// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request, agentOptions?.ChatOptions?.RawRepresentationFactory);
var chatOptions = InputConverter.ConvertToChatOptions(request);
chatOptions.Instructions = request.Instructions;
// Everything the agent needs for this turn is already in the input, so the provider it would
// otherwise run is replaced for the duration by one that keeps its messages in memory and is
// dropped when the run ends. Serving from a longer-lived one would deliver the conversation
// twice, and storing into it would leave a copy the hosting service never sees.
chatOptions.AdditionalProperties ??= [];
chatOptions.AdditionalProperties.Add<ChatHistoryProvider>(new VolatileChatHistoryProvider());
// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
@@ -480,9 +445,9 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// Persist session after streaming completes (successful or not). The user id partitions the
// persisted session per end user, mirroring the load above so multi-turn continuity is preserved.
if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId))
if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId))
{
await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
@@ -43,8 +42,7 @@ public abstract class AgentSessionStore
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a serialized agent session from persistent storage, or <see langword="null"/> when
/// no session is stored for the given identifiers.
/// Retrieves a serialized agent session from persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
@@ -57,41 +55,12 @@ public abstract class AgentSessionStore
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous retrieval operation. The task result contains the restored
/// session, or <see langword="null"/> when nothing is stored for the given identifiers. This is a plain
/// lookup: it never creates a session. Use <see cref="GetOrCreateSessionAsync"/> to get a ready-to-use
/// session (loading an existing one or creating a new one), and use this method when the caller needs to
/// distinguish a resumed session from a fresh one (a non-null result means a prior turn established it).
/// A task that represents the asynchronous retrieval operation.
/// The task result contains the session, or a new session if not found.
/// </returns>
public abstract ValueTask<AgentSession?> GetSessionAsync(
public abstract ValueTask<AgentSession> GetSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the stored session for the given identifiers, or creates a new one via
/// <see cref="AIAgent.CreateSessionAsync"/> when none is stored.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
/// <param name="userId">The per-user partition key; see <see cref="GetSessionAsync"/> for its meaning.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is always a usable session, never <see langword="null"/>.</returns>
/// <remarks>
/// This is the convenience path for callers that only need a session to work with and do not care whether
/// it was loaded or freshly created. It is implemented in terms of <see cref="GetSessionAsync"/>, so a
/// store overriding that method gets this behavior for free.
/// </remarks>
public virtual async ValueTask<AgentSession> GetOrCreateSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false)
?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -208,7 +208,7 @@ public sealed class FileSystemAgentSessionStore : AgentSessionStore
$"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore).";
/// <inheritdoc/>
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
@@ -216,13 +216,13 @@ public sealed class FileSystemAgentSessionStore : AgentSessionStore
string path = this.GetSessionPath(agent, conversationId, userId);
if (!File.Exists(path))
{
return null;
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
return null;
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
// Parse and clone so the document buffer can be released.
@@ -40,15 +40,16 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
}
/// <inheritdoc/>
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
var key = GetKey(agent, conversationId, userId);
if (!this._sessions.TryGetValue(key, out var existingSession))
{
return null;
}
JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;
return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false);
return sessionContent switch
{
null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
_ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
};
}
// Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store
@@ -7,8 +7,6 @@ using System.Text;
using System.Text.Json;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions;
using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
@@ -89,14 +87,10 @@ internal static class InputConverter
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
/// </summary>
/// <param name="request">The create response request.</param>
/// <param name="agentRawRepresentationFactory">
/// The factory the agent carries on its own <see cref="ChatOptions"/>, if any, so a request that has
/// to set one of its own can run it rather than replace it.
/// </param>
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
public static ChatOptions ConvertToChatOptions(CreateResponse request, Func<IChatClient, object?>? agentRawRepresentationFactory = null)
public static ChatOptions ConvertToChatOptions(CreateResponse request)
{
var options = new ChatOptions
return new ChatOptions
{
Temperature = (float?)request.Temperature,
TopP = (float?)request.TopP,
@@ -106,43 +100,6 @@ internal static class InputConverter
// the client-provided model would override it (causing failures when
// clients send placeholder values like "hosted-agent").
};
// The service behind the agent's chat client is never asked to store a response. Recording a
// hosted turn is the AgentServer SDK's job, done by its storage provider around this handler,
// and a second recording downstream is a conversation nothing here reads and no one reconciles.
// The caller's own store flag is not carried across: it says what the hosting service should
// record, which is a separate question and one this handler has no say in.
//
// Both OpenAI request shapes carry the setting, so a chat client speaking either protocol is
// covered. Anything else is a request type with no notion of storing a response, and is handed
// back untouched; such a client keeping a conversation of its own is caught later by the
// conversation id check in the handler.
//
// The agent's own factory is invoked here and its result is what gets the setting, because
// ChatClientAgent chains the two by taking the agent's only when the request's returns null
// (ChatClientAgent.PrepareChatOptions). A request factory that always answers would otherwise
// drop whatever the container configured.
options.RawRepresentationFactory = chatClient =>
{
switch (agentRawRepresentationFactory?.Invoke(chatClient))
{
case CreateResponseOptions responseOptions:
responseOptions.StoredOutputEnabled = false;
return responseOptions;
case ChatCompletionOptions completionOptions:
completionOptions.StoredOutputEnabled = false;
return completionOptions;
case { } configuredByTheAgent:
return configuredByTheAgent;
default:
return new CreateResponseOptions { StoredOutputEnabled = false };
}
};
return options;
}
/// <summary>
@@ -1,55 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// A <see cref="ChatHistoryProvider"/> that holds the turn's messages in a field, for the lifetime of
/// one request and no longer.
/// </summary>
/// <remarks>
/// <para>
/// A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider, which
/// writes every turn the caller asked it to store and serves it back through
/// <see cref="Azure.AI.AgentServer.Responses.ResponseContext.GetHistoryAsync"/>. That happens around
/// the handler, not through it. The handler reads the conversation from there and passes it in as the
/// run's input, so nothing has to be carried between requests, and a provider storing anything of its
/// own would only add a copy the storage provider never sees.
/// </para>
/// <para>
/// Within a single run the provider still does its ordinary work: an agent calling tools goes back to
/// the chat client several times, and each of those calls needs the messages the earlier ones produced.
/// Those live here until the run ends and the instance is dropped.
/// </para>
/// <para>
/// Supplied as a run-scoped override through <see cref="ChatOptions.AdditionalProperties"/>, so it takes
/// the place of the agent's own provider for the turn without changing the agent. An agent that does not
/// read its history through a provider ignores it.
/// </para>
/// </remarks>
internal sealed class VolatileChatHistoryProvider : ChatHistoryProvider
{
private readonly List<ChatMessage> _messages = [];
/// <inheritdoc />
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new(this._messages);
/// <inheritdoc />
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
// Only what this run produced arrives here: the base class filters out everything already marked
// as chat history, which covers the turns the handler took from the storage provider.
this._messages.AddRange(context.RequestMessages);
if (context.ResponseMessages is not null)
{
this._messages.AddRange(context.ResponseMessages);
}
return default;
}
}
@@ -30,20 +30,19 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
/// <remarks>
/// <para>
/// <strong>Trust model.</strong> The A2A <c>contextId</c> and <c>taskId</c> arrive
/// from the wire and are treated as chain-resume identifiers — <em>not</em> as
/// authorization tokens. Both the <see cref="AgentSessionStore"/> and
/// <see cref="ITaskStore"/> contracts carry no principal/owner dimension by default,
/// so when a persistent store is registered any caller who knows or guesses another
/// caller's <c>contextId</c> or <c>taskId</c> can access that other caller's data.
/// Hosts that serve more than one user must compose a principal dimension into the
/// lookup key — typically by calling <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
/// dimension, so when a persistent store is registered any caller who knows or
/// guesses another caller's <c>contextId</c> can resume that other caller's
/// persisted thread. Hosts that serve more than one user must compose a principal
/// dimension into the lookup key — typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="AgentIsolationKeyProvider"/>). When an <see cref="AgentIsolationKeyProvider"/>
/// is registered, both the session store and the task store are automatically wrapped
/// with tenant-scoped isolation. When no isolation provider is registered, behavior
/// is unchanged — the bare identifiers are used directly, which is appropriate for
/// first-run / single-user / prototyping scenarios but unsafe for multi-user hosts.
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
/// conversation identifier, which is appropriate for first-run / single-user /
/// prototyping scenarios but unsafe for multi-user hosts.
/// </para>
/// </remarks>
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
@@ -66,10 +65,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -92,10 +91,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -117,10 +116,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -155,10 +154,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -180,8 +179,6 @@ public static class A2AServerServiceCollectionExtensions
private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
{
var isolationKeyProvider = serviceProvider.GetService<AgentIsolationKeyProvider>();
var agentHandler = serviceProvider.GetKeyedService<IAgentHandler>(agent.Name);
if (agentHandler is null)
{
@@ -189,6 +186,7 @@ public static class A2AServerServiceCollectionExtensions
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new NoopAgentSessionStore();
@@ -203,13 +201,7 @@ public static class A2AServerServiceCollectionExtensions
}
var loggerFactory = serviceProvider.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
ITaskStore taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
// Wrap the task store with isolation key scoping, same as the session store above.
if (taskStore is not IsolationKeyScopedTaskStore)
{
taskStore = new IsolationKeyScopedTaskStore(taskStore, isolationKeyProvider, strict: isolationKeyProvider != null);
}
var taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
return new A2AServer(
agentHandler,
@@ -1,217 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using A2A;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// A delegating <see cref="ITaskStore"/> that scopes task keys by an isolation key
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that tasks are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
/// <remarks>
/// <para>
/// This class mirrors the isolation pattern of <see cref="IsolationKeyScopedAgentSessionStore"/>
/// but applies it to the A2A task store, preventing cross-tenant task access in multi-tenant deployments.
/// </para>
/// <para>
/// Both the store key and the persisted <see cref="AgentTask.ContextId"/> are scoped with the isolation
/// key. Scoping the persisted context is what allows list queries to be constrained to the calling
/// tenant, because list results are matched against the task body rather than the store key. Scoped
/// values are stripped again before being returned, so callers only ever observe bare identifiers.
/// </para>
/// </remarks>
public sealed class IsolationKeyScopedTaskStore : ITaskStore
{
private readonly ITaskStore _innerStore;
private readonly AgentIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedTaskStore"/> class.
/// </summary>
/// <param name="innerStore">The underlying <see cref="ITaskStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="strict">
/// When <see langword="true"/>, an <see cref="InvalidOperationException"/> is thrown if the isolation key
/// cannot be determined. When <see langword="false"/>, the task ID is passed through unmodified.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
public IsolationKeyScopedTaskStore(
ITaskStore innerStore,
AgentIsolationKeyProvider? keyProvider,
bool strict)
{
ArgumentNullException.ThrowIfNull(innerStore);
this._innerStore = innerStore;
this._keyProvider = keyProvider;
this._strict = strict;
}
/// <inheritdoc />
public async Task<AgentTask?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
var task = await this._innerStore.GetTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false);
return task is null ? null : UnscopeTask(task, key);
}
/// <inheritdoc />
public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
await this._innerStore.SaveTaskAsync(ScopeId(taskId, key), ScopeTask(task, key), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
await this._innerStore.DeleteTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
/// <remarks>
/// <see cref="ListTasksResponse.TotalSize"/> is reported by the inner store. When no
/// <see cref="ListTasksRequest.ContextId"/> filter is supplied it therefore counts tasks across all
/// isolation keys, because a wrapper cannot narrow the count without enumerating the whole store.
/// The returned tasks themselves are always constrained to the current isolation key.
/// </remarks>
public async Task<ListTasksResponse> ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
if (key is not null && !string.IsNullOrEmpty(request.ContextId))
{
// Clone the request to avoid mutating the caller's object.
request = CloneRequestWithContextId(request, ScopeId(request.ContextId!, key));
}
var response = await this._innerStore.ListTasksAsync(request, cancellationToken).ConfigureAwait(false);
if (key is null)
{
return response;
}
// Tasks are persisted with a scoped ContextId, so any entry that does not carry the current
// isolation key belongs to another tenant and must not be returned.
var scopedTasks = new List<AgentTask>(response.Tasks.Count);
foreach (var task in response.Tasks)
{
if (IsInScope(task, key))
{
scopedTasks.Add(UnscopeTask(task, key));
}
}
response.Tasks = scopedTasks;
response.PageSize = scopedTasks.Count;
return response;
}
/// <summary>
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
/// </summary>
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
}
return key;
}
/// <summary>
/// Escapes special characters in the isolation key to ensure unambiguous scoped identifiers.
/// </summary>
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
/// <summary>
/// Prefixes a bare identifier with the escaped isolation key, or returns it unchanged when no key applies.
/// </summary>
private static string ScopeId(string id, string? key)
=> key is null ? id : $"{EscapeIsolationKey(key)}::{id}";
/// <summary>
/// Strips the isolation key prefix from a scoped identifier, or returns it unchanged when the prefix is absent.
/// </summary>
private static string UnscopeId(string scopedId, string? key)
{
if (key is null)
{
return scopedId;
}
string prefix = $"{EscapeIsolationKey(key)}::";
return scopedId.StartsWith(prefix, StringComparison.Ordinal)
? scopedId.Substring(prefix.Length)
: scopedId;
}
/// <summary>
/// Determines whether a persisted task carries the supplied isolation key.
/// </summary>
private static bool IsInScope(AgentTask task, string key)
=> task.ContextId?.StartsWith($"{EscapeIsolationKey(key)}::", StringComparison.Ordinal) == true;
/// <summary>
/// Creates a copy of the task whose <see cref="AgentTask.ContextId"/> is scoped by the isolation key.
/// </summary>
/// <remarks>
/// The task instance is copied rather than mutated because the A2A server reuses it for live event
/// notification after persisting; mutating it would surface the scoped context on the wire.
/// </remarks>
private static AgentTask ScopeTask(AgentTask task, string? key)
=> key is null ? task : CloneTaskWithContextId(task, ScopeId(task.ContextId, key));
/// <summary>
/// Creates a copy of the task whose <see cref="AgentTask.ContextId"/> has the isolation key removed.
/// </summary>
private static AgentTask UnscopeTask(AgentTask task, string? key)
=> key is null ? task : CloneTaskWithContextId(task, UnscopeId(task.ContextId, key));
private static AgentTask CloneTaskWithContextId(AgentTask task, string contextId)
=> new()
{
Id = task.Id,
ContextId = contextId,
Status = task.Status,
History = task.History,
Artifacts = task.Artifacts,
Metadata = task.Metadata,
};
private static ListTasksRequest CloneRequestWithContextId(ListTasksRequest request, string contextId)
=> new()
{
ContextId = contextId,
Tenant = request.Tenant,
Status = request.Status,
PageSize = request.PageSize,
PageToken = request.PageToken,
HistoryLength = request.HistoryLength,
StatusTimestampAfter = request.StatusTimestampAfter,
IncludeArtifacts = request.IncludeArtifacts,
};
}
@@ -91,9 +91,9 @@ public static class AGUIEndpointRouteBuilderExtensions
/// principal dimension into the lookup key. The recommended way is to wrap the
/// keyed <see cref="AgentSessionStore"/> in
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="AgentIsolationKeyProvider"/>) and registering the store via the
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
/// isolation provider is registered, behavior is unchanged — the bare
@@ -113,7 +113,7 @@ public static class AGUIEndpointRouteBuilderExtensions
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
var isolationKeyProvider = endpoints.ServiceProvider.GetService<AgentIsolationKeyProvider>();
var isolationKeyProvider = endpoints.ServiceProvider.GetService<SessionIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new NoopAgentSessionStore();
@@ -11,63 +11,63 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// An <see cref="AgentIsolationKeyProvider"/> that extracts an isolation key for agent-owned resources
/// from a claim in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// </summary>
/// <remarks>
/// <para>
/// This provider is suitable for ASP.NET Core web applications where agent-owned resources are partitioned
/// by authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
/// from the ambient <see cref="HttpContext"/>.
/// </para>
/// <para>
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
/// must uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
/// would receive the same isolation key and could read or overwrite one another's persisted data.
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
/// provider maps a different claim).
/// </para>
/// <para>
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
/// is missing, the provider returns <see langword="null"/>. Consuming stores then enforce strict or
/// pass-through behavior based on their configuration.
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
/// will then enforce strict or pass-through behavior based on its configuration.
/// </para>
/// <para>
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
/// to provide access to the current <see cref="HttpContext"/>.
/// </para>
/// </remarks>
public class ClaimsIdentityAgentIsolationKeyProvider : AgentIsolationKeyProvider
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly string _claimType;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentityAgentIsolationKeyProvider"/> class.
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
/// </summary>
/// <param name="httpContextAccessor">
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
/// </param>
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
/// <exception cref="ArgumentException">
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// </exception>
public ClaimsIdentityAgentIsolationKeyProvider(
public ClaimsIdentitySessionIsolationKeyProvider(
IHttpContextAccessor? httpContextAccessor,
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
{
options ??= new ClaimsIdentityAgentIsolationKeyProviderOptions();
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
this._httpContextAccessor = httpContextAccessor;
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
}
/// <summary>
/// Extracts the isolation key for agent-owned resources from the current user's claims.
/// Extracts the session isolation key from the current user's claims.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
@@ -81,7 +81,7 @@ public class ClaimsIdentityAgentIsolationKeyProvider : AgentIsolationKeyProvider
/// unauthenticated identity. The claim value is retrieved from <c>HttpContext.User.Claims</c>; if
/// multiple claims of the specified type exist, the first match is returned.
/// </remarks>
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
@@ -5,12 +5,12 @@ using System.Security.Claims;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Options for configuring <see cref="ClaimsIdentityAgentIsolationKeyProvider"/>.
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentityAgentIsolationKeyProviderOptions
public class ClaimsIdentitySessionIsolationKeyProviderOptions
{
/// <summary>
/// Gets or sets the claim type to extract from the user's identity to isolate agent-owned resources.
/// Gets or sets the claim type to extract from the user's identity for session isolation.
/// </summary>
/// <remarks>
/// <para>
@@ -28,8 +28,8 @@ public class ClaimsIdentityAgentIsolationKeyProviderOptions
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
/// across all callers. Two distinct principals that share the same value for a non-unique claim
/// would receive the same agent-isolation key and could read or overwrite one another's
/// persisted data. Only override this value with a claim that is guaranteed unique and stable.
/// would receive the same session-isolation key and could read or overwrite one another's
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
/// </para>
/// <para>
/// Common alternatives include:
@@ -13,11 +13,11 @@ namespace Microsoft.Agents.AI.Hosting;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers an <see cref="AgentIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate isolation keys for agent-owned resources.
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate session isolation keys.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="options">Optional configuration for the claims-based isolation key provider.</param>
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
/// <remarks>
/// <para>
@@ -31,24 +31,24 @@ public static class ServiceCollectionExtensions
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
/// such as Entra's <c>oid</c>) should override
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
/// absent, which causes strict-mode stores to fail.
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
/// absent, which causes strict-mode session stores to fail.
/// </para>
/// <para>
/// <strong>Security warning:</strong> If you override
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
/// uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
/// the host can prove their uniqueness across all callers, because distinct principals that share the
/// same claim value would receive the same isolation key and could access one another's persisted data.
/// same claim value would receive the same isolation key and could access one another's sessions.
/// </para>
/// </remarks>
public static IServiceCollection UseClaimsBasedAgentIsolation(
public static IServiceCollection UseClaimsBasedSessionIsolation(
this IServiceCollection services,
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
{
options ??= new();
ServiceDescriptor descriptor = new(typeof(AgentIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
services.Add(descriptor);
return services;
@@ -57,7 +57,7 @@ public static class ServiceCollectionExtensions
{
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
return new ClaimsIdentityAgentIsolationKeyProvider(contextAccessor, options);
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
}
}
}
@@ -27,10 +27,10 @@ namespace Microsoft.Agents.AI.Hosting;
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
/// that other caller's persisted thread. The framework provides
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
/// <c>sessionStoreId</c> to include an isolation key resolved from an
/// <see cref="AgentIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentityAgentIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedAgentIsolation(...)</c>). When no provider is registered, the
/// <c>sessionStoreId</c> to include an isolation key resolved from a
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
/// store behaves as a single-namespace persistence layer — appropriate for
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
/// </para>
@@ -59,7 +59,7 @@ public static class HostedAgentBuilderExtensions
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
var isolationKeyProvider = sp.GetService<AgentIsolationKeyProvider>();
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
// Best efforts options getting
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
@@ -8,12 +8,12 @@ namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that sessions are isolated
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
{
private readonly AgentIsolationKeyProvider? _keyProvider;
private readonly SessionIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
@@ -21,7 +21,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
/// </summary>
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
/// <exception cref="ArgumentNullException">
@@ -29,7 +29,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
/// </exception>
public IsolationKeyScopedAgentSessionStore(
AgentSessionStore innerStore,
AgentIsolationKeyProvider? keyProvider,
SessionIsolationKeyProvider? keyProvider,
IsolationKeyScopedAgentSessionStoreOptions? options = null)
: base(innerStore)
{
@@ -51,12 +51,12 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
}
return key;
@@ -13,7 +13,7 @@ public class IsolationKeyScopedAgentSessionStoreOptions
/// <remarks>
/// <para>
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
/// when <see cref="AgentIsolationKeyProvider.GetIsolationKeyAsync"/> returns <see langword="null"/>.
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
/// </para>
/// <para>
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
@@ -32,9 +32,9 @@ namespace Microsoft.Agents.AI.Hosting;
/// or guesses another caller's identifier can resume that other caller's persisted
/// thread. Multi-user hosts must wrap this store in
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
/// <see cref="AgentIsolationKeyProvider"/>) so that the conversation namespace is
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
/// scoped per principal. See the trust-model remarks on
/// <see cref="AgentSessionStore"/> for the full background.
/// </para>
@@ -6,26 +6,24 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides an abstract base class for resolving keys that isolate resources owned by hosted agents.
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
/// </summary>
/// <remarks>
/// <para>
/// The <c>Agent</c> prefix identifies the hosting API domain; it does not mean that agent instances
/// themselves are isolated. The returned key scopes agent-owned resources, such as sessions and A2A
/// tasks, to a logical partition (e.g., user ID, tenant ID, or composite key). Other agent resources,
/// such as memory or retrieval data, can use the same key when they require the same isolation boundary.
/// Derived classes implement the key resolution logic appropriate to their hosting environment.
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
/// implement the key resolution logic appropriate to their hosting environment.
/// </para>
/// <para>
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
/// Consuming stores can then enforce strict behavior (throwing an exception) or fall back to unscoped
/// storage based on their configuration.
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
/// to unscoped storage based on its configuration.
/// </para>
/// </remarks>
public abstract class AgentIsolationKeyProvider
public abstract class SessionIsolationKeyProvider
{
/// <summary>
/// Asynchronously retrieves the isolation key for agent-owned resources in the current request or execution context.
/// Asynchronously retrieves the session isolation key for the current request or execution context.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
@@ -37,5 +35,5 @@ public abstract class AgentIsolationKeyProvider
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
/// the caller to decide on strict vs. pass-through behavior.
/// </remarks>
public abstract ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default);
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
}
@@ -4,7 +4,6 @@ using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
@@ -133,80 +132,13 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) :
agent.RunStreamingAsync([], null, runOptions, cancellationToken);
await foreach (AgentResponseUpdate update in WithFailureDetectionAsync(agentResponse, agentVersionResult.Name, cancellationToken).ConfigureAwait(false))
await foreach (AgentResponseUpdate update in agentResponse.ConfigureAwait(false))
{
update.AuthorName = agentVersionResult.Name;
yield return update;
}
}
/// <summary>
/// Surfaces a failed Responses API run as <see cref="ErrorContent"/>.
/// </summary>
/// <remarks>
/// <para>
/// <c>Microsoft.Extensions.AI.OpenAI</c> maps the <c>response.failed</c> event onto a
/// contentless update, leaving a failed run indistinguishable from an empty successful one.
/// </para>
/// <para>
/// The failed update is replaced rather than supplemented: it carries the provider's error text
/// in its raw representation, and updates reach clients verbatim regardless of the host's
/// exception-detail policy.
/// </para>
/// </remarks>
internal static async IAsyncEnumerable<AgentResponseUpdate> WithFailureDetectionAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
string? authorName,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (AgentResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
update.AuthorName = authorName;
yield return TryCreateFailureUpdate(update, authorName, out AgentResponseUpdate? failureUpdate)
? failureUpdate
: update;
}
}
/// <summary>
/// Builds an <see cref="ErrorContent"/> update when <paramref name="update"/> represents a failed run.
/// </summary>
private static bool TryCreateFailureUpdate(
AgentResponseUpdate update,
string? authorName,
[NotNullWhen(true)] out AgentResponseUpdate? failureUpdate)
{
failureUpdate = null;
if (update.RawRepresentation is not ChatResponseUpdate chatUpdate ||
chatUpdate.RawRepresentation is not StreamingResponseFailedUpdate failedUpdate)
{
return false;
}
ResponseError? error = failedUpdate.Response?.Error;
// A failure with no detail must still explain itself to the client.
ErrorContent errorContent =
new(string.IsNullOrWhiteSpace(error?.Message) ? DefaultFailureMessage : error!.Message)
{
ErrorCode = error?.Code.ToString() is { Length: > 0 } code ? code : DefaultFailureCode,
};
failureUpdate =
new(ChatRole.Assistant, [errorContent])
{
AuthorName = authorName,
ResponseId = update.ResponseId ?? failedUpdate.Response?.Id,
CreatedAt = update.CreatedAt,
};
return true;
}
private const string DefaultFailureMessage = "The agent run failed.";
private const string DefaultFailureCode = "failed";
private async Task<ProjectsAgentVersion> QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default)
{
string agentKey = $"{agentName}:{agentVersion}";
@@ -77,9 +77,7 @@ internal static class AgentProviderExtensions
updates.Add(update);
// Error updates are withheld: they reach the client verbatim, bypassing the host's
// exception-detail policy. The detail still arrives via the thrown exception.
if (autoSend && !HasError(update))
if (autoSend)
{
await context.AddEventAsync(new AgentResponseUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false);
}
@@ -87,9 +85,6 @@ internal static class AgentProviderExtensions
AgentResponse response = updates.ToAgentResponse();
// Fail before the response is announced as completed or copied to the conversation.
ThrowIfFailed(response, agentName);
if (autoSend)
{
await context.AddEventAsync(new AgentResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false);
@@ -116,41 +111,4 @@ internal static class AgentProviderExtensions
}
}
}
/// <summary>
/// Indicates whether an update carries an agent error rather than usable content.
/// </summary>
private static bool HasError(AgentResponseUpdate update) =>
update.Contents.Any(content => content is ErrorContent);
/// <summary>
/// Fails the action when the agent reported an error rather than a usable response.
/// </summary>
/// <remarks>
/// Any top-level <see cref="ErrorContent"/> is a failure, covering both a failed run and a
/// refusal, and excluding <c>incomplete</c>, which carries partial content instead. The detail
/// is folded into the exception so one error path stays under the host's exception-detail policy.
/// </remarks>
private static void ThrowIfFailed(AgentResponse response, string agentName)
{
// The last error wins: a run that fails without detail yields a generic placeholder first,
// and the specific cause follows as its own error.
ErrorContent? error =
response.Messages
.SelectMany(message => message.Contents)
.OfType<ErrorContent>()
.LastOrDefault();
if (error is null)
{
return;
}
string errorCode = string.IsNullOrWhiteSpace(error.ErrorCode) ? "unknown" : error.ErrorCode!;
string errorMessage = string.IsNullOrWhiteSpace(error.Message) ? "No error message was provided." : error.Message!;
// No inner exception: DeclarativeActionException is unwrapped to its inner exception when
// reported, which would discard this message.
throw new DeclarativeActionException($"Agent '{agentName}' failed [{errorCode}]: {errorMessage}");
}
}
@@ -2,40 +2,29 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class StringExtensions
internal static partial class StringExtensions
{
private const string JsonDelimiter = "```";
#if NET
[GeneratedRegex(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Multiline)]
private static partial Regex TrimJsonDelimiterRegex();
#else
private static Regex TrimJsonDelimiterRegex() => s_trimJsonDelimiterRegex;
private static readonly Regex s_trimJsonDelimiterRegex = new(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Compiled | RegexOptions.Multiline);
#endif
public static string TrimJsonDelimiter(this string value)
{
value = value.Trim();
// Scan linearly so malformed fenced input cannot trigger regex backtracking.
int openingDelimiterIndex = FindOpeningDelimiter(value);
if (openingDelimiterIndex < 0)
{
return value;
}
int contentIndex = openingDelimiterIndex + JsonDelimiter.Length;
while (contentIndex < value.Length && IsWordCharacter(value[contentIndex]))
{
contentIndex++;
}
while (contentIndex < value.Length && char.IsWhiteSpace(value[contentIndex]))
{
contentIndex++;
}
int closingDelimiterIndex = FindClosingDelimiter(value, contentIndex);
return closingDelimiterIndex < 0 ?
value :
value.Substring(contentIndex, closingDelimiterIndex - contentIndex).Trim();
Match match = TrimJsonDelimiterRegex().Match(value);
return match.Success ?
match.Groups[1].Value.Trim() :
value;
}
public static FormulaValue ToFormula(this string? value) =>
@@ -45,54 +34,6 @@ internal static class StringExtensions
public static string FormatName(this string identifier) => FormatIdentifier(identifier, skipFirst: true);
private static int FindOpeningDelimiter(string value)
{
for (int index = 0; index <= value.Length - JsonDelimiter.Length; index++)
{
if ((index == 0 || value[index - 1] == '\n') && IsDelimiterAt(value, index))
{
return index;
}
}
return -1;
}
private static int FindClosingDelimiter(string value, int startIndex)
{
for (int index = startIndex; index <= value.Length - JsonDelimiter.Length; index++)
{
if (IsDelimiterAt(value, index) && IsLineEnd(value, index + JsonDelimiter.Length))
{
return index;
}
}
return -1;
}
private static bool IsDelimiterAt(string value, int index) =>
value[index] == '`' &&
value[index + 1] == '`' &&
value[index + 2] == '`';
private static bool IsLineEnd(string value, int index) =>
index == value.Length ||
value[index] == '\n' ||
(value[index] == '\r' && index + 1 < value.Length && value[index + 1] == '\n');
// Keep language qualifier handling compatible with .NET regex \w semantics.
private static bool IsWordCharacter(char value) =>
char.GetUnicodeCategory(value) is
UnicodeCategory.UppercaseLetter or
UnicodeCategory.LowercaseLetter or
UnicodeCategory.TitlecaseLetter or
UnicodeCategory.ModifierLetter or
UnicodeCategory.OtherLetter or
UnicodeCategory.NonSpacingMark or
UnicodeCategory.DecimalDigitNumber or
UnicodeCategory.ConnectorPunctuation;
private static string FormatIdentifier(string identifier, bool skipFirst = false)
{
string[] words = identifier.Split('_');
@@ -132,7 +132,7 @@ internal sealed class MessageMerger
_ = finishReasons.Add(response.FinishReason.Value);
}
usage = UsageAggregator.Combine(usage, response.Usage);
usage = MergeUsage(usage, response.Usage);
additionalProperties = MergeProperties(additionalProperties, response.AdditionalProperties);
}
@@ -219,7 +219,7 @@ internal sealed class MessageMerger
Messages = current.Messages.Concat(incoming.Messages).ToList(),
ResponseId = current.ResponseId,
RawRepresentation = rawRepresentation,
Usage = UsageAggregator.Combine(current.Usage, incoming.Usage),
Usage = MergeUsage(current.Usage, incoming.Usage),
};
}
@@ -269,5 +269,40 @@ internal sealed class MessageMerger
return merged;
}
static UsageDetails? MergeUsage(UsageDetails? current, UsageDetails? incoming)
{
if (current is null)
{
return incoming;
}
AdditionalPropertiesDictionary<long>? additionalCounts = current.AdditionalCounts;
if (incoming is null)
{
return current;
}
if (additionalCounts is null)
{
additionalCounts = incoming.AdditionalCounts;
}
else if (incoming.AdditionalCounts is not null)
{
foreach (string key in incoming.AdditionalCounts.Keys)
{
additionalCounts[key] = incoming.AdditionalCounts[key] +
(additionalCounts.TryGetValue(key, out long? existingCount) ? existingCount.Value : 0);
}
}
return new UsageDetails
{
InputTokenCount = current.InputTokenCount + incoming.InputTokenCount,
OutputTokenCount = current.OutputTokenCount + incoming.OutputTokenCount,
TotalTokenCount = current.TotalTokenCount + incoming.TotalTokenCount,
AdditionalCounts = additionalCounts,
};
}
}
}
@@ -8,7 +8,6 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectSharedUsage>true</InjectSharedUsage>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
@@ -1,9 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -240,41 +238,6 @@ public sealed class ChatClientAgentOptions
/// </value>
public bool DisableApprovalResponseBinding { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable bypassing that stores invocable (backend) function
/// calls in the session state and executes them on the next request when they are returned alongside
/// declaration-only (frontend) function calls in the same response.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> terminates the function-calling loop as soon as it encounters
/// a non-invocable (declaration-only) <see cref="FunctionCallContent"/>, returning every
/// <see cref="FunctionCallContent"/> in that iteration — including invocable backend calls — to the caller
/// unexecuted. When the caller only resolves the declaration-only call (for example an AG-UI frontend
/// tool), the backend call's <c>call_id</c> is left orphaned, which causes the AI provider to reject the
/// next request.
/// </para>
/// <para>
/// When this property is set to <see langword="true"/>, an <see cref="InvocableFunctionBypassingChatClient"/>
/// decorator is injected above <see cref="FunctionInvokingChatClient"/> in the pipeline. For responses that
/// contain both invocable and declaration-only function calls, the decorator removes the invocable calls,
/// stores them in the session, and returns only the declaration-only calls to the caller. On the next
/// request the stored calls are re-injected as pre-approved responses so
/// <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </para>
/// <para>
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When using a custom chat client stack, you can add an <see cref="InvocableFunctionBypassingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseInvocableFunctionBypassing"/>
/// extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableInvocableFunctionBypassing { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -295,6 +258,5 @@ public sealed class ChatClientAgentOptions
EnableMessageInjection = this.EnableMessageInjection,
DisableApprovalNotRequiredFunctionBypassing = this.DisableApprovalNotRequiredFunctionBypassing,
DisableApprovalResponseBinding = this.DisableApprovalResponseBinding,
EnableInvocableFunctionBypassing = this.EnableInvocableFunctionBypassing,
};
}
@@ -2,11 +2,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
@@ -223,50 +221,4 @@ public static class ChatClientBuilderExtensions
return builder.Use((innerClient, services) =>
new ApprovalResponseBindingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}
/// <summary>
/// Adds an <see cref="InvocableFunctionBypassingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline.
/// When <see cref="FunctionInvokingChatClient"/> returns a response containing both an invocable (backend)
/// <see cref="FunctionCallContent"/> and a declaration-only (frontend) <see cref="FunctionCallContent"/> in
/// the same iteration, this decorator removes the invocable calls, stores them in the session, and returns
/// only the declaration-only calls to the caller. On the next request the stored calls are re-injected as
/// pre-approved responses so <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </para>
/// <para>
/// If the pipeline also contains an <see cref="ApprovalResponseBindingChatClient"/>, this decorator must be
/// positioned <em>below</em> it. That client drops any <see cref="ToolApprovalResponseContent"/> that does not
/// correspond to a request it recorded, so that a forged approval cannot execute. The responses this decorator
/// injects are synthetic and have no such request, so placing the binding client below this decorator would
/// silently discard them and prevent the stored calls from ever executing.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
/// <see cref="ChatClientAgentOptions.EnableInvocableFunctionBypassing"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator is intended for use within the context of a running <see cref="ChatClientAgent"/> with
/// an active session. When invoked outside of an agent run (for example when the built chat client is used
/// directly), the decorator becomes a no-op, passing the request through unchanged and logging a warning.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <param name="loggerFactory">
/// An optional <see cref="ILoggerFactory"/> used to create a logger for the decorator. When not provided,
/// the factory is resolved from the pipeline's <see cref="IServiceProvider"/>; if none is available,
/// logging is a no-op.
/// </param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseInvocableFunctionBypassing(this ChatClientBuilder builder, ILoggerFactory? loggerFactory = null)
{
return builder.Use((innerClient, services) =>
new InvocableFunctionBypassingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}
}
@@ -68,7 +68,7 @@ public static class ChatClientExtensions
// ApprovalNotRequiredFunctionBypassingChatClient is registered before FunctionInvokingChatClient so that
// it sits above FICC in the pipeline. ChatClientBuilder.Build applies factories in reverse order,
// making the first Use() call outermost. By adding this decorator here, the resulting pipeline is:
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → [InvocableFunctionBypassingChatClient] → FunctionInvokingChatClient
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → FunctionInvokingChatClient
// → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → DeferredOpenTelemetryChatClient → leaf IChatClient
// This allows the decorator to intercept FICC's responses and remove approval requests for tools
// that don't actually require approval, storing them for automatic re-injection on the next request.
@@ -78,18 +78,6 @@ public static class ChatClientExtensions
new ApprovalNotRequiredFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}
// InvocableFunctionBypassingChatClient is opt-in via EnableInvocableFunctionBypassing. It is
// registered after the approval decorators and immediately before FunctionInvokingChatClient, so it
// sits directly above FICC (ChatClientBuilder.Build applies factories in reverse order). It intercepts
// FICC responses that contain both invocable (backend) and declaration-only (frontend) function calls,
// removes the invocable calls, stores them in the session, and re-injects them as pre-approved
// responses on the next request so FICC reconstructs and executes them.
if (options?.EnableInvocableFunctionBypassing is true)
{
chatBuilder.Use((innerClient, services) =>
new InvocableFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
chatBuilder.Use((innerClient, services) =>
@@ -1,451 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that lets an agent expose both invocable (backend) tools and
/// declaration-only (frontend) tools at the same time, working around
/// <see cref="FunctionInvokingChatClient"/> terminating the function-calling loop before invoking
/// sibling backend tool calls when a declaration-only tool call appears in the same iteration.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> terminates the loop as soon as it encounters a
/// non-invocable (declaration-only) <see cref="FunctionCallContent"/>, returning every
/// <see cref="FunctionCallContent"/> in that iteration — including invocable backend calls — to the
/// caller unexecuted.
/// </para>
/// <para>
/// This decorator sits above <see cref="FunctionInvokingChatClient"/> in the pipeline. On outbound
/// responses that contain <em>both</em> an invocable (backend) <see cref="FunctionCallContent"/> and a
/// declaration-only <see cref="FunctionCallContent"/>, it removes the invocable calls from the response,
/// stores them in the session's <see cref="AgentSessionStateBag"/>, and returns only the declaration-only
/// calls to the caller to resolve. On the next request — after the caller has resolved the
/// declaration-only calls — the stored invocable calls are re-injected as pre-approved
/// <see cref="ToolApprovalResponseContent"/> so that <see cref="FunctionInvokingChatClient"/> reconstructs
/// and executes them, producing the missing <see cref="FunctionResultContent"/>.
/// </para>
/// <para>
/// The stored calls are re-injected as approved <see cref="ToolApprovalResponseContent"/> rather than as
/// bare <see cref="FunctionCallContent"/> because <see cref="FunctionInvokingChatClient"/> only re-executes
/// calls that arrive from incoming history as approval responses; it does not execute bare
/// <see cref="FunctionCallContent"/> present in the history. This is the same mechanism used by
/// <see cref="ApprovalNotRequiredFunctionBypassingChatClient"/>, and a lone approved response without a
/// matching request in the history is accepted.
/// </para>
/// <para>
/// This decorator operates within the context of a running <see cref="AIAgent"/> with an active
/// <see cref="AgentRunContext.Session"/>. When invoked without an ambient run context or session
/// (for example when the chat client is used directly outside of an agent run), the decorator becomes
/// a no-op: it passes the request through to the inner client unchanged and logs a warning.
/// </para>
/// <para>
/// When the pipeline also contains an <see cref="ApprovalResponseBindingChatClient"/>, this decorator must sit
/// <em>below</em> it. That client drops any <see cref="ToolApprovalResponseContent"/> without a request it
/// recorded, so that a forged approval cannot execute; the responses injected here are synthetic and have no
/// such request. The default agent pipeline already orders them correctly.
/// </para>
/// </remarks>
internal sealed partial class InvocableFunctionBypassingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used in <see cref="AgentSessionStateBag"/> to store bypassed invocable function calls
/// between agent runs.
/// </summary>
internal const string StateBagKey = "_bypassedInvocableFunctionCalls";
private readonly ILogger _logger;
private bool _warnedNoSession;
/// <summary>
/// Initializes a new instance of the <see cref="InvocableFunctionBypassingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
/// <param name="loggerFactory">An optional <see cref="ILoggerFactory"/> used to create a logger for diagnostics.</param>
public InvocableFunctionBypassingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null)
: base(innerClient)
{
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<InvocableFunctionBypassingChatClient>();
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
if (!this.TryGetSession(out var session))
{
return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
}
messages = InjectPendingBypassedCalls(messages, session);
var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
this.RemoveAndStoreBypassableInvocableCalls(response.Messages, options, session);
return response;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (!this.TryGetSession(out var session))
{
await foreach (var passthrough in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
yield return passthrough;
}
yield break;
}
messages = InjectPendingBypassedCalls(messages, session);
// Stream updates live until a surfaced (non-informational) FunctionCallContent appears, then hold the
// tail so the strip/store decision can observe every call in the same batch before re-emitting.
// No whole-response coalescing is required because FunctionInvokingChatClient emits each call as a
// complete FunctionCallContent (it never splits a call across updates).
//
// FunctionInvokingChatClient buffers per iteration: it yields its buffered updates at the end of each
// iteration and then streams the next iteration live. When it invokes a call locally it flips
// FunctionCallContent.InformationalOnly to true in place, on the very instances held here. A buffered
// tail whose calls have all become informational therefore has nothing left to strip, so it is
// released and live streaming resumes. Only a genuinely bypassable batch (where the loop terminated,
// leaving the calls non-informational) is held to the end of the stream.
List<ChatResponseUpdate>? tail = null;
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
if (tail is not null && !ContainsNonInformationalFunctionCall(tail))
{
foreach (var buffered in tail)
{
yield return buffered;
}
tail = null;
}
if (tail is null && !UpdateHasNonInformationalFunctionCall(update))
{
yield return update;
continue;
}
(tail ??= []).Add(update);
}
if (tail is null)
{
yield break;
}
var contentLists = new IList<AIContent>[tail.Count];
for (int i = 0; i < tail.Count; i++)
{
contentLists[i] = tail[i].Contents;
}
this.StripAndStoreBypassableInvocableCalls(contentLists, options, session);
// Every buffered update is surfaced, including any left with no contents by the stripping above. An
// update carries metadata beyond its contents — ConversationId, ContinuationToken, ResponseId,
// MessageId, RawRepresentation and more — so dropping one would discard state the caller needs, and a
// content-free update is unremarkable in a stream. This differs from the non-streaming path, where an
// emptied message is removed because it would otherwise be persisted to history and resent to the
// provider on the next turn.
foreach (var update in tail)
{
yield return update;
}
}
/// <summary>
/// Attempts to get the current <see cref="AgentSession"/> from the ambient run context. When no run
/// context or session is available, logs a warning (once per instance) and returns <see langword="false"/>
/// so the caller can pass the request through without applying bypassing.
/// </summary>
private bool TryGetSession([NotNullWhen(true)] out AgentSession? session)
{
session = AIAgent.CurrentRunContext?.Session;
if (session is null)
{
if (!this._warnedNoSession)
{
this._warnedNoSession = true;
LogBypassingSkipped(this._logger);
}
return false;
}
return true;
}
[LoggerMessage(LogLevel.Warning, "InvocableFunctionBypassingChatClient was invoked without an active agent run context or session. Invocable function bypassing is skipped and all function calls are surfaced to the caller. Invoke the chat client through AIAgent.RunAsync or AIAgent.RunStreamingAsync to enable bypassing.")]
private static partial void LogBypassingSkipped(ILogger logger);
/// <summary>
/// Checks the session for invocable function calls stored on a previous turn and injects them as
/// a user message containing pre-approved <see cref="ToolApprovalResponseContent"/> items appended to
/// the input messages, so that <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </summary>
/// <remarks>
/// <para>
/// Pending calls are consumed exactly once: the session entry is removed here and is never put back, so a
/// turn that fails or whose stream is abandoned drops them.
/// </para>
/// <para>
/// That is deliberate. The calls are injected as one batch and the service expects a
/// <see cref="FunctionResultContent"/> for every <see cref="FunctionCallContent"/> in it.
/// <see cref="FunctionInvokingChatClient"/> invokes approved approval responses before the main loop and
/// before any downstream service call, so by the time a request fails part of the batch has usually
/// already run — and because a failure surfaces as an exception rather than a response, the results of
/// those invocations are unrecoverable. Re-injecting the remainder would therefore still leave the batch
/// incomplete while invoking already-executed functions a second time. Dropping the calls avoids the
/// duplicate invocation, and matches
/// <see cref="ApprovalNotRequiredFunctionBypassingChatClient"/>, which likewise never restores its entry.
/// </para>
/// </remarks>
/// <param name="messages">The outgoing messages.</param>
/// <param name="session">The session holding any calls bypassed on a previous turn.</param>
private static IEnumerable<ChatMessage> InjectPendingBypassedCalls(
IEnumerable<ChatMessage> messages,
AgentSession session)
{
if (!session.StateBag.TryGetValue(
StateBagKey,
out List<FunctionCallContent>? pendingCalls,
AgentJsonUtilities.DefaultOptions)
|| pendingCalls is not { Count: > 0 })
{
return messages;
}
session.StateBag.TryRemoveValue(StateBagKey);
List<AIContent> approvalResponses = [];
foreach (var call in pendingCalls)
{
// FunctionInvokingChatClient reconstructs and executes the call from the approval response
// itself; the request is synthetic and does not need to be present in the history.
var request = new ToolApprovalRequestContent(ComposeApprovalRequestId(call.CallId), call);
approvalResponses.Add(request.CreateResponse(approved: true));
}
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
return messages.Concat([userMessage]);
}
/// <summary>
/// Composes the approval-request id for a bypassed call. The prefix deliberately differs from the
/// <c>ficc_</c> prefix <see cref="FunctionInvokingChatClient"/> uses for its own approval requests, so
/// that a synthetic id can never collide with a genuine one.
/// </summary>
private static string ComposeApprovalRequestId(string callId) => $"ifbcc_{callId}";
/// <summary>
/// Builds the set of invocable (backend) tool names and the set of declaration-only (frontend) tool
/// names from <see cref="ChatOptions.Tools"/> and <see cref="FunctionInvokingChatClient.AdditionalTools"/>.
/// </summary>
private (HashSet<string> Invocable, HashSet<string> DeclarationOnly) GetToolNameSets(ChatOptions? options)
{
var ficc = this.GetService<FunctionInvokingChatClient>();
var allTools = (options?.Tools ?? Enumerable.Empty<AITool>())
.Concat(ficc?.AdditionalTools ?? Enumerable.Empty<AITool>());
HashSet<string> invocable = new(StringComparer.Ordinal);
HashSet<string> declarationOnly = new(StringComparer.Ordinal);
foreach (var tool in allTools)
{
// AIFunction derives from AIFunctionDeclaration, so check the invocable type first.
if (tool is AIFunction function)
{
invocable.Add(function.Name);
}
else if (tool is AIFunctionDeclaration declaration)
{
declarationOnly.Add(declaration.Name);
}
}
return (invocable, declarationOnly);
}
/// <summary>
/// Returns <see langword="true"/> if the update contains a non-informational
/// <see cref="FunctionCallContent"/> (a call that was surfaced to the caller rather than executed by
/// <see cref="FunctionInvokingChatClient"/>).
/// </summary>
private static bool UpdateHasNonInformationalFunctionCall(ChatResponseUpdate update)
=> update.Contents.Any(c => c is FunctionCallContent { InformationalOnly: false });
/// <summary>
/// Returns <see langword="true"/> if any content list contains a non-informational
/// <see cref="FunctionCallContent"/> (a call that was surfaced to the caller rather than executed by
/// <see cref="FunctionInvokingChatClient"/>).
/// </summary>
private static bool ContainsNonInformationalFunctionCall(IList<AIContent>[] contentLists)
=> contentLists.Any(contents => contents.Any(c => c is FunctionCallContent { InformationalOnly: false }));
/// <summary>
/// Returns <see langword="true"/> if any buffered update still contains a non-informational
/// <see cref="FunctionCallContent"/>. Once <see cref="FunctionInvokingChatClient"/> has invoked a call it
/// flips <see cref="FunctionCallContent.InformationalOnly"/> to <see langword="true"/> in place, so a
/// buffer for which this returns <see langword="false"/> holds nothing that can be bypassed.
/// </summary>
private static bool ContainsNonInformationalFunctionCall(List<ChatResponseUpdate> updates)
=> updates.Any(UpdateHasNonInformationalFunctionCall);
/// <summary>
/// When a response contains both an invocable (backend) <see cref="FunctionCallContent"/> and a
/// declaration-only (frontend) <see cref="FunctionCallContent"/>, removes the invocable calls from the
/// response and stores them in the session for re-injection and execution on the next request.
/// </summary>
private void RemoveAndStoreBypassableInvocableCalls(
IList<ChatMessage> messages,
ChatOptions? options,
AgentSession session)
{
var contentLists = new IList<AIContent>[messages.Count];
for (int i = 0; i < messages.Count; i++)
{
contentLists[i] = messages[i].Contents;
}
var emptied = this.StripAndStoreBypassableInvocableCalls(contentLists, options, session);
if (emptied is null)
{
return;
}
// Remove messages that were emptied by stripping bypassed content (high index first). Messages that
// were already empty (for example metadata-only messages) are left untouched. Unlike a streaming
// update, an emptied message is worth removing because it would otherwise be persisted to the
// conversation history and resent to the provider on the next turn.
for (int i = messages.Count - 1; i >= 0; i--)
{
if (emptied.Contains(i))
{
messages.RemoveAt(i);
}
}
}
/// <summary>
/// Applies the both-kinds gate over the supplied content lists and, when both an invocable and a
/// declaration-only <see cref="FunctionCallContent"/> are present, removes the invocable calls in place
/// (in document order) and stores them in the session for re-injection on the next request.
/// </summary>
/// <returns>
/// The set of content-list indices that were emptied by the removal, or <see langword="null"/> when
/// nothing was bypassed.
/// </returns>
private HashSet<int>? StripAndStoreBypassableInvocableCalls(
IList<AIContent>[] contentLists,
ChatOptions? options,
AgentSession session)
{
// Cheap first pass: the common case is a text-only response with no function calls. Avoid allocating
// the tool-name sets unless the response actually contains at least one non-informational call.
if (!ContainsNonInformationalFunctionCall(contentLists))
{
return null;
}
var (invocable, declarationOnly) = this.GetToolNameSets(options);
if (invocable.Count == 0 || declarationOnly.Count == 0)
{
// The mixed backend/frontend scenario is impossible without at least one of each kind of tool.
return null;
}
bool hasInvocableCall = false;
bool hasDeclarationOnlyCall = false;
foreach (var contents in contentLists)
{
foreach (var content in contents)
{
if (content is FunctionCallContent { InformationalOnly: false } fcc)
{
if (declarationOnly.Contains(fcc.Name))
{
hasDeclarationOnlyCall = true;
}
else if (invocable.Contains(fcc.Name))
{
hasInvocableCall = true;
}
}
}
}
// Only bypass when the two kinds coexist in the same response. An all-invocable response is already
// handled by FunctionInvokingChatClient (never surfaced unexecuted), and an all-declaration-only
// response is the normal frontend-tools flow that must pass through unchanged.
if (!hasInvocableCall || !hasDeclarationOnlyCall)
{
return null;
}
List<FunctionCallContent>? bypassed = null;
HashSet<int>? emptied = null;
for (int i = 0; i < contentLists.Length; i++)
{
var contents = contentLists[i];
bool removedFromList = false;
// Forward scan collects the bypassed calls in document order.
for (int j = 0; j < contents.Count;)
{
if (contents[j] is FunctionCallContent { InformationalOnly: false } fcc
&& invocable.Contains(fcc.Name)
&& !declarationOnly.Contains(fcc.Name))
{
(bypassed ??= []).Add(fcc);
contents.RemoveAt(j);
removedFromList = true;
}
else
{
j++;
}
}
if (removedFromList && contents.Count == 0)
{
(emptied ??= []).Add(i);
}
}
if (bypassed is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, bypassed, AgentJsonUtilities.DefaultOptions);
}
return emptied;
}
}
@@ -77,28 +77,22 @@ public sealed class MessageInjectingChatClient : DelegatingChatClient
// are pending but new messages have been injected into the queue, we call the service again
// so the model can process them. The loop exits when the response contains actionable
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
// Usage is accumulated across every iteration so the returned response reports the token cost
// of all service calls made, not just the last one.
UsageDetails? aggregatedUsage = null;
while (true)
{
var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false);
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
// loop will iterate — return immediately so it can process them.
if (HasActionableFunctionCalls(response.Messages))
{
return response.ApplyAggregatedUsage(aggregatedUsage);
return response;
}
// No actionable function calls. If there are pending injected messages, loop again
// to send them to the service. Otherwise, we're done.
if (await this.IsQueueEmptyAsync(session, cancellationToken).ConfigureAwait(false))
{
return response.ApplyAggregatedUsage(aggregatedUsage);
return response;
}
// Propagate any ConversationId returned by the service so subsequent iterations
@@ -163,10 +163,6 @@ public sealed class LoopAgent : DelegatingAIAgent
// followed by that iteration's response messages. Unused when only the final response is returned.
List<ChatMessage> transcript = [];
// Accumulates usage across every inner invocation so the returned response reports the token cost of the
// whole run rather than only its final iteration. Aggregated even when only the last response is returned.
UsageDetails? aggregatedUsage = null;
// The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration).
IReadOnlyList<ChatMessage> currentSurfaced = [];
@@ -178,8 +174,6 @@ public sealed class LoopAgent : DelegatingAIAgent
AgentResponse response = await this.InnerAgent.RunAsync(currentMessages, activeSession, options, cancellationToken).ConfigureAwait(false);
iteration++;
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
// Record this iteration's on-behalf-of input (before the response it elicited) and the response itself.
transcript.AddRange(currentSurfaced);
transcript.AddRange(response.Messages);
@@ -195,21 +189,21 @@ public sealed class LoopAgent : DelegatingAIAgent
// Stop and surface the response when the agent is waiting for a tool approval.
if (HasPendingApprovalRequests(response))
{
return this.BuildResult(response, transcript, aggregatedUsage);
return this.BuildResult(response, transcript);
}
// Enforce the global safety cap regardless of what the evaluators want.
if (iteration >= this._maxIterations)
{
this.LogMaxIterationsReached(iteration);
return this.BuildResult(response, transcript, aggregatedUsage);
return this.BuildResult(response, transcript);
}
// Ask the evaluators whether to continue; stop when none of them request a re-invocation.
LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false);
if (!step.ShouldContinue)
{
return this.BuildResult(response, transcript, aggregatedUsage);
return this.BuildResult(response, transcript);
}
currentMessages = step.Messages;
@@ -452,14 +446,27 @@ public sealed class LoopAgent : DelegatingAIAgent
}
/// <summary>
/// Produces the non-streaming run result from the final iteration's response, which carries either its own
/// messages (when configured) or the full transcript of the run. In both cases the usage reported is
/// <paramref name="aggregatedUsage"/>, covering every iteration of the run.
/// Produces the non-streaming run result: either the final iteration's response (when configured) or an
/// aggregated response carrying the full transcript with the final response's metadata.
/// </summary>
private AgentResponse BuildResult(AgentResponse lastResponse, List<ChatMessage> transcript, UsageDetails? aggregatedUsage)
=> this._nonStreamingReturnsLastResponseOnly
? lastResponse.ApplyAggregatedUsage(aggregatedUsage)
: lastResponse.ApplyAggregatedUsage(aggregatedUsage, transcript);
private AgentResponse BuildResult(AgentResponse lastResponse, List<ChatMessage> transcript)
{
if (this._nonStreamingReturnsLastResponseOnly)
{
return lastResponse;
}
return new AgentResponse(transcript)
{
AgentId = lastResponse.AgentId,
ResponseId = lastResponse.ResponseId,
CreatedAt = lastResponse.CreatedAt,
FinishReason = lastResponse.FinishReason,
Usage = lastResponse.Usage,
AdditionalProperties = lastResponse.AdditionalProperties,
ContinuationToken = lastResponse.ContinuationToken,
};
}
private static bool HasPendingApprovalRequests(AgentResponse response)
{
@@ -9,7 +9,6 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -29,9 +28,6 @@ namespace Microsoft.Agents.AI;
/// first is returned to the caller while the rest are queued. On subsequent calls, queued items are re-evaluated
/// against rules (which may have been updated by the caller's "always approve" response) and presented one at a time.
/// Once all queued requests are resolved, the collected responses are injected and the inner agent is called again.
/// This one-at-a-time behavior no longer applies once the auto-approval cap
/// (<see cref="ToolApprovalAgentOptions.MaxAutoApprovalIterations"/>) is reached: the final inner turn is returned
/// as-is, so more than one approval request may be surfaced to the caller at once.
/// </item>
/// <item>
/// <b>Inbound (caller to agent):</b> When the caller sends an <see cref="AlwaysApproveToolApprovalResponseContent"/>,
@@ -51,13 +47,9 @@ namespace Microsoft.Agents.AI;
/// </remarks>
public sealed class ToolApprovalAgent : DelegatingAIAgent
{
/// <summary>The default value used for <see cref="ToolApprovalAgentOptions.MaxAutoApprovalIterations"/> when none is specified.</summary>
public const int DefaultMaxAutoApprovalIterations = 40;
private readonly ProviderSessionState<ToolApprovalState> _sessionState;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly Func<ToolAutoApprovalRuleContext, ValueTask<bool>>[]? _autoApprovalRules;
private readonly int _maxAutoApprovalIterations;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
@@ -73,8 +65,6 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
{
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
this._autoApprovalRules = options?.AutoApprovalRules?.ToArray();
this._maxAutoApprovalIterations = Throw.IfLessThan(
options?.MaxAutoApprovalIterations ?? DefaultMaxAutoApprovalIterations, 1);
this._sessionState = new ProviderSessionState<ToolApprovalState>(
_ => new ToolApprovalState(),
"toolApprovalState",
@@ -135,48 +125,20 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
// 3. Call the inner agent in a loop. If the inner agent returns approval requests
// that are ALL auto-approved by standing rules, we immediately re-call with the
// collected approval responses injected. This avoids returning empty responses.
//
// The loop is bounded by _maxAutoApprovalIterations. Each pass is a fresh inner
// invocation, so a per-request cap (FunctionInvokingChatClient.MaximumIterationsPerRequest)
// restarts every time and cannot bound it; without a cap here a model that keeps
// requesting an auto-approved tool bills indefinitely.
//
// Usage is accumulated across every re-invocation so the caller sees the token cost
// of the whole run, not just its final inner call.
UsageDetails? aggregatedUsage = null;
for (int iteration = 0; ; iteration++)
while (true)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
if (iteration >= this._maxAutoApprovalIterations)
{
// Cap reached: take one final turn without auto-approving again, so any approval
// request it surfaces goes to the caller to decide rather than continuing the chain.
// Returning here without this call would hand back a response whose approval requests
// were already stripped — the empty response the loop exists to avoid.
var cappedResponse = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
// This turn is still part of the same run, so its usage joins the aggregate rather
// than replacing it; otherwise hitting the cap would discard every prior turn's cost.
UsageAggregator.Accumulate(ref aggregatedUsage, cappedResponse.Usage);
return cappedResponse.ApplyAggregatedUsage(aggregatedUsage);
}
var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
// Classify approval requests: auto-approve matching, queue excess, keep first unapproved.
bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session, options, requestMessages).ConfigureAwait(false);
if (!allAutoApproved)
{
// Response has real content or an unapproved approval request — return to caller,
// reporting the usage accumulated across every turn of the run.
return response.ApplyAggregatedUsage(aggregatedUsage);
// Response has real content or an unapproved approval request — return to caller.
return response;
}
// All approval requests were auto-approved. Loop to re-invoke with them injected.
@@ -211,26 +173,11 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
// are auto-approved by standing rules, we immediately re-stream with the collected
// approval responses injected. This avoids returning empty streams.
//
// Bounded by _maxAutoApprovalIterations for the same reason as the non-streaming path:
// every pass is a fresh inner invocation, so no per-request cap can bound it.
for (int iteration = 0; ; iteration++)
while (true)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
if (iteration >= this._maxAutoApprovalIterations)
{
// Cap reached: take one final turn without auto-approving again. Updates are yielded
// as-is, so any approval request reaches the caller to decide instead of continuing.
await foreach (var update in this.InnerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
// Stream from the inner agent. Non-approval content is yielded immediately.
// Approval requests are collected (not yielded) so we can classify the full batch.
List<ToolApprovalRequestContent> streamedApprovalRequests = [];
@@ -46,24 +46,4 @@ public class ToolApprovalAgentOptions
/// </para>
/// </remarks>
public IEnumerable<Func<ToolAutoApprovalRuleContext, ValueTask<bool>>>? AutoApprovalRules { get; set; }
/// <summary>
/// Gets or sets the safety cap on how many times the inner agent is re-invoked within a single run
/// because every surfaced approval request was auto-approved, or <see langword="null"/> to use
/// <see cref="ToolApprovalAgent.DefaultMaxAutoApprovalIterations"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each re-invocation is a fresh call to the inner agent, so a per-request cap such as
/// <c>FunctionInvokingChatClient.MaximumIterationsPerRequest</c> restarts every time and cannot bound this
/// loop. Without this cap a model that keeps requesting an auto-approved tool, drives an unbounded sequence of
/// billable model calls.
/// </para>
/// <para>
/// On reaching the cap the agent performs one final inner invocation without auto-approving again, so any
/// remaining approval request is surfaced to the caller to decide instead of being approved automatically.
/// Raise this value if you intend to allow longer auto-approval chains.
/// </para>
/// </remarks>
public int? MaxAutoApprovalIterations { get; set; }
}
@@ -9,7 +9,6 @@
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectSharedRedaction>true</InjectSharedRedaction>
<InjectSharedUsage>true</InjectSharedUsage>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
@@ -5,7 +5,6 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Security;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -22,7 +21,6 @@ namespace Microsoft.Agents.AI;
/// </summary>
/// <remarks>
/// Searches directories recursively (up to 2 levels deep) for SKILL.md files.
/// Symbolic links and reparse points below configured roots are not followed during skill discovery.
/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
/// Resource and script paths are checked against path traversal and symlink escape attacks.
@@ -116,7 +114,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
var discoveredPaths = this.DiscoverSkillDirectories(this._skillPaths);
var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
@@ -140,7 +138,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
return Task.FromResult(skills as IList<AgentSkill>);
}
private List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
private static List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
{
var discoveredPaths = new List<string>();
@@ -151,23 +149,17 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
continue;
}
this.SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
}
return discoveredPaths;
}
private void SearchDirectoriesForSkills(string directory, List<string> results, int currentDepth)
private static void SearchDirectoriesForSkills(string directory, List<string> results, int currentDepth)
{
string skillFilePath = Path.Combine(directory, SkillFileName);
if (File.Exists(skillFilePath))
{
if (IsLinkOrReparsePointOrInaccessible(skillFilePath))
{
LogUnsafeSkillDiscoveryPath(this._logger, SanitizePathForLog(skillFilePath));
return;
}
// Once a SKILL.md is found, this directory is the skill root.
// Subdirectories are part of this skill and should not be treated as independent skill roots.
results.Add(Path.GetFullPath(directory));
@@ -179,15 +171,9 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
return;
}
foreach (string subdirectory in this.SafeEnumerateDirectories(directory, attributesToSkip: 0))
foreach (string subdirectory in Directory.EnumerateDirectories(directory))
{
if (IsLinkOrReparsePointOrInaccessible(subdirectory))
{
LogUnsafeSkillDiscoveryPath(this._logger, SanitizePathForLog(subdirectory));
continue;
}
this.SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
}
}
@@ -338,9 +324,8 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point or cannot be inspected. The root directory is excluded —
// it's a caller-supplied trusted path, and the security boundary guards files within it,
// not the path itself.
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -401,8 +386,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point or cannot be inspected.
// e.g. "references/secret.md" → symlink to "/etc/shadow"
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -429,7 +413,11 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory, FileAttributes.ReparsePoint))
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
{
this.ScanDirectoryForResources(subdirectory, skillDirectoryFullPath, skillName, resources, currentDepth + 1);
}
@@ -464,9 +452,8 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point or cannot be inspected. The root directory is excluded —
// it's a caller-supplied trusted path, and the security boundary guards files within it,
// not the path itself.
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -514,8 +501,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point or cannot be inspected.
// e.g. "scripts/run.py" → symlink to "/etc/shadow"
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -542,7 +528,11 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory, FileAttributes.ReparsePoint))
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
{
this.ScanDirectoryForScripts(subdirectory, skillDirectoryFullPath, skillName, scripts, currentDepth + 1);
}
@@ -550,8 +540,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Checks whether any segment in the path (relative to the directory) is a symlink,
/// reparse point, or cannot be inspected.
/// Checks whether any segment in the path (relative to the directory) is a symlink.
/// </summary>
private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath)
{
@@ -566,7 +555,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
currentPath = Path.Combine(currentPath, segment);
if (IsLinkOrReparsePointOrInaccessible(currentPath))
if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
{
return true;
}
@@ -575,56 +564,30 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
return false;
}
private static bool IsLinkOrReparsePointOrInaccessible(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
catch (Exception ex) when (IsFileSystemInspectionFailure(ex))
{
return true;
}
}
private static bool IsFileSystemInspectionFailure(Exception exception)
{
return exception is IOException or UnauthorizedAccessException or SecurityException;
}
#if !NET
/// <summary>
/// Best-effort directory enumeration that returns an empty array when the
/// directory cannot be inspected, so a single inaccessible child does not
/// abort the entire skill scan.
/// Best-effort directory enumeration for target frameworks without
/// <c>EnumerationOptions.IgnoreInaccessible</c> support. Returns an empty
/// array when the caller lacks permission to read the directory contents,
/// so a single inaccessible child does not abort the entire skill scan.
/// </summary>
private string[] SafeEnumerateDirectories(string path, FileAttributes attributesToSkip)
private string[] SafeEnumerateDirectories(string path)
{
try
{
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = attributesToSkip,
};
return Directory.EnumerateDirectories(path, "*", enumerationOptions).ToArray();
#else
_ = attributesToSkip;
return Directory.GetDirectories(path);
#endif
}
catch (Exception ex) when (IsFileSystemInspectionFailure(ex))
catch (UnauthorizedAccessException)
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogDirectoryInspectionFailed(this._logger, SanitizePathForLog(path));
LogDirectoryAccessDenied(this._logger, SanitizePathForLog(path));
}
return Array.Empty<string>();
}
}
#endif
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
{
@@ -757,9 +720,6 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")]
private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
[LoggerMessage(LogLevel.Warning, "Skipping skill discovery path '{Path}': symbolic link or reparse point detected, or path could not be inspected")]
private static partial void LogUnsafeSkillDiscoveryPath(ILogger logger, string path);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
@@ -772,10 +732,10 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' contains a symbolic link or reparse point, or could not be inspected")]
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symbolic link or reparse point, or could not be inspected")]
[LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
@@ -784,12 +744,12 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")]
private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' contains a symbolic link or reparse point, or could not be inspected")]
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symbolic link or reparse point, or could not be inspected")]
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': directory could not be inspected")]
private static partial void LogDirectoryInspectionFailed(ILogger logger, string directoryPath);
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': access denied")]
private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath);
}
@@ -1,70 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Extension methods for reporting usage aggregated by <see cref="UsageAggregator"/> on the response that
/// concludes a run.
/// </summary>
internal static class UsageAggregationExtensions
{
/// <summary>
/// Reports <paramref name="aggregatedUsage"/> on <paramref name="response"/> in place of the usage it
/// carries, which typically covers only the final service call of a run.
/// </summary>
/// <param name="response">The response produced by the final call of the run.</param>
/// <param name="aggregatedUsage">The usage accumulated across every call that made up the run.</param>
/// <param name="messages">
/// The messages the response should carry, or <see langword="null"/> to keep those it already has. Used
/// when a run returns a transcript spanning multiple calls.
/// </param>
/// <returns>The same <paramref name="response"/> instance, updated in place.</returns>
/// <remarks>
/// The supplied response is updated rather than copied, so that a derived response type returned by an
/// inner client (along with any state it carries) survives the aggregation. This matches how
/// <see cref="FunctionInvokingChatClient"/> reports the usage it accumulates across function-calling
/// iterations. Only the response is mutated: <paramref name="aggregatedUsage"/> is a freshly combined
/// instance, so no <see cref="UsageDetails"/> owned by an inner client is modified.
/// </remarks>
public static ChatResponse ApplyAggregatedUsage(this ChatResponse response, UsageDetails? aggregatedUsage, IList<ChatMessage>? messages = null)
{
if (messages is not null)
{
response.Messages = messages;
}
response.Usage = aggregatedUsage;
return response;
}
/// <summary>
/// Reports <paramref name="aggregatedUsage"/> on <paramref name="response"/> in place of the usage it
/// carries, which typically covers only the final invocation of a run.
/// </summary>
/// <param name="response">The response produced by the final invocation of the run.</param>
/// <param name="aggregatedUsage">The usage accumulated across every invocation that made up the run.</param>
/// <param name="messages">
/// The messages the response should carry, or <see langword="null"/> to keep those it already has. Used
/// when a run returns a transcript spanning multiple invocations.
/// </param>
/// <returns>The same <paramref name="response"/> instance, updated in place.</returns>
/// <remarks>
/// The supplied response is updated rather than copied, so that a derived response type returned by an
/// inner agent (such as <see cref="AgentResponse{T}"/>, along with any state it carries) survives the
/// aggregation. Only the response is mutated: <paramref name="aggregatedUsage"/> is a freshly combined
/// instance, so no <see cref="UsageDetails"/> owned by an inner agent is modified.
/// </remarks>
public static AgentResponse ApplyAggregatedUsage(this AgentResponse response, UsageDetails? aggregatedUsage, IList<ChatMessage>? messages = null)
{
if (messages is not null)
{
response.Messages = messages;
}
response.Usage = aggregatedUsage;
return response;
}
}
-123
View File
@@ -1,123 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Combines <see cref="UsageDetails"/> reported by the individual service or agent invocations that make up
/// a single logical run.
/// </summary>
/// <remarks>
/// Several components re-invoke an inner agent or chat client in a loop within a single run (for example
/// when auto-approving tool calls, injecting messages, or re-running an agent until an evaluator is
/// satisfied). Each inner invocation reports its own usage, and the aggregate must be surfaced to the
/// caller so that the reported token counts reflect the entire run rather than just its final step.
/// </remarks>
internal static class UsageAggregator
{
/// <summary>
/// Combines two <see cref="UsageDetails"/> instances into a new instance containing their summed values.
/// </summary>
/// <param name="current">The running aggregate, or <see langword="null"/> if nothing has been accumulated yet.</param>
/// <param name="incoming">The usage reported by the latest invocation, or <see langword="null"/> if none was reported.</param>
/// <returns>
/// A new <see cref="UsageDetails"/> containing the summed token counts and additional counts, or
/// <see langword="null"/> when both <paramref name="current"/> and <paramref name="incoming"/> are
/// <see langword="null"/>.
/// </returns>
/// <remarks>
/// Neither argument is mutated, and neither argument is ever returned by reference, since both may be
/// owned and observed by callers and <see cref="UsageDetails.Add"/> combines in place. Every
/// strongly-typed counter exposed by <see cref="UsageDetails"/> is summed, matching the set covered by
/// <see cref="UsageDetails.Add"/>, so that no provider-reported counter is lost when a combined instance
/// replaces the original. Token counts are summed in a null-aware manner: combining a
/// <see langword="null"/> count with a non-null count yields the non-null count, and combining two
/// <see langword="null"/> counts yields <see langword="null"/>. Entries in
/// <see cref="UsageDetails.AdditionalCounts"/> are summed per key so that provider-specific counters
/// (such as cached, reasoning, or cost counters) aggregate correctly.
/// </remarks>
public static UsageDetails? Combine(UsageDetails? current, UsageDetails? incoming)
{
if (current is null && incoming is null)
{
return null;
}
var combined = new UsageDetails
{
InputTokenCount = AddCounts(current?.InputTokenCount, incoming?.InputTokenCount),
OutputTokenCount = AddCounts(current?.OutputTokenCount, incoming?.OutputTokenCount),
TotalTokenCount = AddCounts(current?.TotalTokenCount, incoming?.TotalTokenCount),
CachedInputTokenCount = AddCounts(current?.CachedInputTokenCount, incoming?.CachedInputTokenCount),
ReasoningTokenCount = AddCounts(current?.ReasoningTokenCount, incoming?.ReasoningTokenCount),
InputAudioTokenCount = AddCounts(current?.InputAudioTokenCount, incoming?.InputAudioTokenCount),
InputTextTokenCount = AddCounts(current?.InputTextTokenCount, incoming?.InputTextTokenCount),
OutputAudioTokenCount = AddCounts(current?.OutputAudioTokenCount, incoming?.OutputAudioTokenCount),
OutputTextTokenCount = AddCounts(current?.OutputTextTokenCount, incoming?.OutputTextTokenCount),
};
AdditionalPropertiesDictionary<long>? additionalCounts = CombineAdditionalCounts(current?.AdditionalCounts, incoming?.AdditionalCounts);
if (additionalCounts is not null)
{
combined.AdditionalCounts = additionalCounts;
}
return combined;
}
/// <summary>
/// Adds the <paramref name="incoming"/> usage into the running aggregate referenced by
/// <paramref name="current"/>, replacing it with a new combined instance.
/// </summary>
/// <param name="current">The running aggregate to update. May be <see langword="null"/>.</param>
/// <param name="incoming">The usage reported by the latest invocation, or <see langword="null"/> if none was reported.</param>
public static void Accumulate(ref UsageDetails? current, UsageDetails? incoming)
=> current = Combine(current, incoming);
/// <summary>
/// Adds two nullable counts, treating <see langword="null"/> as "not reported" rather than as zero so
/// that an aggregate only reports a count when at least one contributor reported one.
/// </summary>
private static long? AddCounts(long? current, long? incoming)
=> current is null ? incoming : incoming is null ? current : current + incoming;
/// <summary>
/// Produces a new dictionary containing the per-key sums of the supplied additional counts, or
/// <see langword="null"/> when neither side has any entries.
/// </summary>
private static AdditionalPropertiesDictionary<long>? CombineAdditionalCounts(
AdditionalPropertiesDictionary<long>? current,
AdditionalPropertiesDictionary<long>? incoming)
{
bool hasCurrent = current is { Count: > 0 };
bool hasIncoming = incoming is { Count: > 0 };
if (!hasCurrent && !hasIncoming)
{
return null;
}
var combined = new AdditionalPropertiesDictionary<long>();
if (hasCurrent)
{
foreach (var entry in current!)
{
combined[entry.Key] = entry.Value;
}
}
if (hasIncoming)
{
foreach (var entry in incoming!)
{
combined[entry.Key] = combined.TryGetValue(entry.Key, out long existing)
? existing + entry.Value
: entry.Value;
}
}
return combined;
}
}
@@ -1,55 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests.TestContainer;
/// <summary>
/// Wraps the container's agent and tells the caller which conversation the agent's own run left behind
/// on the service, by appending <c>DOWNSTREAM_ID=&lt;id&gt;</c> to the reply.
/// </summary>
/// <remarks>
/// <para>
/// The platform already records every hosted turn around the handler, and that is the conversation the
/// caller reads. The agent's run inside the container talks to its own service, and if that service is
/// asked to keep the turn it writes a second record, on a trail of its own that the caller never sees.
/// </para>
/// <para>
/// After the run, the id of that trail is on the session, so reporting it is enough for a test to go
/// look for it on the service. No id means the container asked for nothing to be kept.
/// </para>
/// </remarks>
internal sealed class DownstreamConversationReportingAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent)
{
/// <summary>
/// Marker that carries the id. Tests read the value that follows it.
/// </summary>
public const string IdPrefix = "DOWNSTREAM_ID=";
/// <summary>
/// Value reported when the agent's run left nothing behind on the service.
/// </summary>
public const string NoId = "none";
/// <inheritdoc />
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var update in this.InnerAgent
.RunStreamingAsync(messages, session, options, cancellationToken)
.ConfigureAwait(false))
{
yield return update;
}
var downstreamId = (session as ChatClientAgentSession)?.ConversationId;
yield return new AgentResponseUpdate(
ChatRole.Assistant,
$" {IdPrefix}{(string.IsNullOrWhiteSpace(downstreamId) ? NoId : downstreamId)}");
}
}
@@ -6,7 +6,6 @@ using Azure.AI.Projects;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Foundry.Hosting.IntegrationTests.TestContainer;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Foundry.Hosting;
@@ -35,7 +34,6 @@ AIAgent agent = scenario switch
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"unsupported-protocol" => CreateHappyPathAgent(projectClient, deployment),
"store-config" => CreateStoreConfigAgent(projectClient, deployment),
"downstream-store" => CreateDownstreamStoreAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
@@ -91,19 +89,6 @@ static AIAgent CreateStoreConfigAgent(AIProjectClient client, string deployment)
name: "store-config-agent",
description: "Store and session semantics test agent.");
// downstream-store scenario: an ordinary Foundry ChatClientAgent, like the first hosted agent sample,
// wrapped so the caller is told which conversation the agent's own run left behind on the service. The
// platform already records the hosted turn in the caller's conversation; anything the agent's run also
// leaves behind is a second copy of the same turn, on a trail nobody reads.
static AIAgent CreateDownstreamStoreAgent(AIProjectClient client, string deployment) =>
new DownstreamConversationReportingAgent(
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Answer the user's question concisely and accurately, " +
"and use any facts the user told you earlier in the conversation.",
name: "downstream-store-agent",
description: "Downstream store test agent."));
static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
@@ -1,14 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=downstream-store</c> mode.
/// Used by <c>HostedDownstreamStoreTests</c>. The container runs an ordinary Foundry
/// <c>ChatClientAgent</c> and reports back which conversation its own run left behind on the service,
/// so the test can check whether a second copy of the turn was kept.
/// </summary>
public sealed class DownstreamStoreHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "downstream-store";
}
@@ -3,7 +3,6 @@
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
@@ -12,7 +11,6 @@ using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
@@ -148,83 +146,6 @@ public abstract class HostedAgentFixture : IAsyncLifetime
return count;
}
/// <summary>
/// Reads every message stored in a conversation, oldest first, as a role and text pair. Used by
/// tests that need to see how many times a given turn was recorded, not just how many items there
/// are.
/// </summary>
public async Task<List<(string Role, string Text)>> ReadConversationMessagesAsync(string conversationId)
{
List<(string Role, string Text)> messages = [];
await foreach (AgentResponseItem item in this.AgentOpenAIClient.GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false))
{
if (item.AsResponseResultItem() is MessageResponseItem message)
{
var text = string.Concat(message.Content
.Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText)
.Select(c => c.Text));
messages.Add((message.Role.ToString(), text));
}
}
return messages;
}
/// <summary>
/// Reads the input a stored response was run with, oldest first, as a role and text pair. Along a
/// <c>previous_response_id</c> chain this is what the turn actually received, so tests can see
/// whether an earlier turn was handed to it more than once.
/// </summary>
public async Task<List<(string Role, string Text)>> ReadResponseInputMessagesAsync(string responseId)
{
List<(string Role, string Text)> messages = [];
await foreach (ResponseItem item in this.AgentOpenAIClient.GetProjectResponsesClient().GetResponseInputItemsAsync(responseId).ConfigureAwait(false))
{
if (item is MessageResponseItem message)
{
var text = string.Concat(message.Content
.Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText)
.Select(c => c.Text));
messages.Add((message.Role.ToString(), text));
}
}
return messages;
}
/// <summary>
/// Tries to read a response back off the service by id, returning <see langword="null"/> when
/// nothing is stored under it. Both the project-wide client and this scenario's per-agent client
/// are tried, because a response created inside the container is not necessarily reachable through
/// the same endpoint as one created for the caller.
/// </summary>
public async Task<object?> TryReadResponseAsync(string responseId)
{
foreach (var responses in new[]
{
this.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient(),
this.AgentOpenAIClient.GetProjectResponsesClient(),
})
{
try
{
var response = await responses.GetResponseAsync(responseId).ConfigureAwait(false);
if (response?.Value is not null)
{
return response.Value;
}
}
catch
{
// Not readable through this endpoint; try the next one.
}
}
return null;
}
public async ValueTask InitializeAsync()
{
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
@@ -1,123 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Proves a hosted turn is kept once.
/// </summary>
/// <remarks>
/// <para>
/// The AgentServer SDK's storage provider records every hosted turn around the container's handler,
/// and that record is the conversation the caller reads. The agent's own run inside the container
/// talks to its own service, and when that service is asked to keep the turn it writes a second copy
/// of the same exchange, on a trail of its own that nobody reads and nobody reconciles. The caller's
/// conversation looks clean, so the second copy goes unnoticed.
/// </para>
/// <para>
/// The container agent here is an ordinary Foundry <c>ChatClientAgent</c>, like the first hosted agent
/// sample. It is wrapped so that after the run it appends <c>DOWNSTREAM_ID=&lt;id&gt;</c> to the reply,
/// carrying whatever its own run left behind on the service. The tests then go looking for that id:
/// finding it means a second copy exists.
/// </para>
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class HostedDownstreamStoreTests(DownstreamStoreHostedAgentFixture fixture) : IClassFixture<DownstreamStoreHostedAgentFixture>
{
private const string IdPrefix = "DOWNSTREAM_ID=";
private const string NoId = "none";
private readonly DownstreamStoreHostedAgentFixture _fixture = fixture;
[Fact]
public async Task StoredTurn_LeavesNothingBehindOnTheAgentsOwnServiceAsync()
{
// Arrange: a session bound to a conversation, which is how a caller keeps a hosted agent on one
// thread. The session carries the conversation, so no per-run options are needed.
var agent = this._fixture.Agent;
var chatClientAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(chatClientAgent);
var conversationId = await this._fixture.CreateConversationAsync();
try
{
var session = await chatClientAgent.CreateSessionAsync(conversationId);
// Act: one stored turn, the way any caller would send it.
var response = await agent.RunAsync("Reply with the word 'ack'.", session);
// Assert: the caller's conversation holds the turn, so it was recorded once already.
var recorded = await this._fixture.ReadConversationMessagesAsync(conversationId);
Assert.NotEmpty(recorded);
// And the agent's own run left nothing behind that can be read back off the service.
await this.AssertNothingWasLeftBehindAsync(response.Text);
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
[Fact]
public async Task MultiTurn_LeavesNothingBehindOnTheAgentsOwnServiceAsync()
{
// Arrange: the agent's own default session, with nothing set up ahead of time. Whatever the
// hosted agent keeps for the caller lands on the session once the first turn comes back.
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var first = await agent.RunAsync("Remember the number 73. Acknowledge briefly.", session);
var second = await agent.RunAsync("What number did I just tell you?", session);
// Assert: the conversation works, so history is reaching the model.
Assert.Contains("73", second.Text);
// The hosted agent handed the caller something to continue from, and it is on the session.
var keptForTheCaller = (session as ChatClientAgentSession)?.ConversationId;
Assert.False(
string.IsNullOrWhiteSpace(keptForTheCaller),
"The hosted agent did not hand the caller anything to continue the conversation from.");
// Every turn's own run, though, left nothing behind on the service.
await this.AssertNothingWasLeftBehindAsync(first.Text);
await this.AssertNothingWasLeftBehindAsync(second.Text);
}
/// <summary>
/// Fails when the id the container reported still resolves on the service, which means the agent's
/// own run kept a second copy of a turn the platform had already recorded.
/// </summary>
private async Task AssertNothingWasLeftBehindAsync(string? replyText)
{
var downstreamId = ParseDownstreamId(replyText);
if (downstreamId is null)
{
return;
}
var found = await this._fixture.TryReadResponseAsync(downstreamId);
Assert.True(
found is null,
$"The agent's own run left a second copy of the turn on the service, readable as '{downstreamId}'.");
}
/// <summary>
/// Reads the id the container reported, or <see langword="null"/> when the run left nothing behind.
/// </summary>
private static string? ParseDownstreamId(string? text)
{
Assert.False(string.IsNullOrWhiteSpace(text));
var marker = text!.IndexOf(IdPrefix, StringComparison.Ordinal);
Assert.True(marker >= 0, $"Expected the container to report '{IdPrefix}...' but got: {text}");
var value = text[(marker + IdPrefix.Length)..].Trim();
return value.Length == 0 || value.Equals(NoId, StringComparison.Ordinal) ? null : value;
}
}
@@ -208,7 +208,6 @@ human-only operation; CI only adds and deletes versions under existing agents.
| --- | --- | --- | --- |
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, and container-instruction behaviour. |
| `HostedResponsesStoreConfigFixture` | `store-config` | `it-store-config` | Store/session semantics: `store=true` vs `store=false`, `previous_response_id` and `conversation_id` forks (read history without appending), multi-turn recall. |
| `DownstreamStoreHostedAgentFixture` | `downstream-store` | `it-downstream-store` | An ordinary Foundry `ChatClientAgent` that reports back which conversation its own run left behind on the service, so the test can assert the container does not keep a second copy of a turn the platform already recorded. |
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
@@ -42,7 +42,6 @@ $ErrorActionPreference = 'Stop'
$Scenarios = @(
'happy-path',
'store-config',
'downstream-store',
'tool-calling',
'tool-calling-approval',
'mcp-toolbox',
@@ -15,8 +15,6 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions;
using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
@@ -713,574 +711,6 @@ public class AgentFrameworkResponseHandlerTests
Assert.IsType<ResponseInProgressEvent>(events[1]);
}
#region Resume detection
[Fact]
public async Task CreateAsync_FirstTurnOfAKnownConversation_StillReceivesTheServiceHistoryAsync()
{
// Arrange: the first turn this container serves for a conversation the service already holds
// history for. Nothing has been persisted for it yet, so this is not a resume: the history has
// to be handed to the agent, otherwise it answers knowing nothing of the conversation.
var agent = new CapturingAgent();
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
var request = new CreateResponse { Model = "test" };
request.Conversation = BinaryData.FromString("\"conv-known\"");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
content = new[] { new { type = "input_text", text = "new question" } } }
});
var ctx = new Mock<ResponseContext>("resp_" + new string('4', 46)) { CallBase = true };
ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", null));
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "earlier turn")]);
ctx.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>())).ReturnsAsync(Array.Empty<Item>());
// Act
await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None));
// Assert: whether this is a resume is answered by the session store, not by looking for state on
// the session. The handler writes the caller's identity onto a session before this point, so a
// freshly created session already carries state and reading that as "it has run before" made the
// first turn of every conversation look like a resume, dropping its history. It only showed up
// when hosted, because there is no identity to write locally.
Assert.NotNull(agent.CapturedMessages);
Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("earlier turn", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_SecondTurnOfAWorkflow_DoesNotReplayTheServiceHistoryAsync()
{
// Arrange: a hosted workflow, whose session carries the conversation in its own state, and a
// first turn that persists it.
const string ConversationId = "conv-resumed";
var agent = new WorkflowLikeAgent();
var store = new InMemoryAgentSessionStore();
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store);
await DrainEventsAsync(handler.CreateAsync(
NewConversationTurn(ConversationId, "first question"),
NewServingContext("resp_" + new string('5', 46), []),
CancellationToken.None));
// Act: a second turn of the same conversation, for which the service now reports history.
await DrainEventsAsync(handler.CreateAsync(
NewConversationTurn(ConversationId, "second question"),
NewServingContext("resp_" + new string('6', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]),
CancellationToken.None));
// Assert: a workflow takes everything handed to it as newly arrived input, and its session
// already holds these turns, so handing them over again would re-drive work it has already done.
Assert.NotNull(agent.CapturedMessages);
Assert.DoesNotContain(agent.CapturedMessages!, m => m.Text.Contains("first question", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_SecondTurnOfAnAgentThatKeepsNothing_StillReceivesTheServiceHistoryAsync()
{
// Arrange: an agent written outside this repo that runs no chat history provider and keeps
// nothing in its session, with a first turn that persists one anyway.
const string ConversationId = "conv-keeps-nothing";
var agent = new CapturingAgent();
var store = new InMemoryAgentSessionStore();
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store);
await DrainEventsAsync(handler.CreateAsync(
NewConversationTurn(ConversationId, "first question"),
NewServingContext("resp_" + new string('7', 46), []),
CancellationToken.None));
// Act: a second turn of the same conversation, for which the service now reports history.
await DrainEventsAsync(handler.CreateAsync(
NewConversationTurn(ConversationId, "second question"),
NewServingContext("resp_" + new string('8', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]),
CancellationToken.None));
// Assert: a persisted session says a prior turn ran here, not that the conversation is inside it.
// Only a workflow keeps its messages that way; anything else starts each turn with nothing, so
// withholding the history would leave it answering blind.
Assert.NotNull(agent.CapturedMessages);
Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("first question", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_WhenTheModelReportsAConversationId_TurnsStillCompleteAsync()
{
// Arrange: a container whose model call reports a conversation id, which is what happens when
// the container's chat client lets the model keep the conversation. The agent records that id on
// the session, and from then on its own conflict policy would reject the provider the host
// registers, failing the turn.
var agent = new ChatClientAgent(
CreateCapturingChatClient([], conversationId: "conv-from-the-model"),
new ChatClientAgentOptions { Name = "hosted" });
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
// Act
var first = await CollectEventNamesAsync(handler, "conv-model", "resp_" + new string('9', 46), "first question");
var second = await CollectEventNamesAsync(handler, "conv-model", "resp_" + new string('a', 46), "second question");
// Assert: both turns run to completion. The host owns history for this agent, so the agent's
// policy of refusing a second history manager must not be left to fire on the host's own
// registration.
Assert.Contains("ResponseCompletedEvent", first);
Assert.DoesNotContain("ResponseFailedEvent", first);
Assert.Contains("ResponseCompletedEvent", second);
Assert.DoesNotContain("ResponseFailedEvent", second);
}
private static async Task<List<string>> CollectEventNamesAsync(
AgentFrameworkResponseHandler handler, string conversationId, string responseId, string text)
{
var names = new List<string>();
await foreach (var evt in handler.CreateAsync(
NewConversationTurn(conversationId, text), NewServingContext(responseId, []), CancellationToken.None))
{
names.Add(evt.GetType().Name);
}
return names;
}
private static CreateResponse NewConversationTurn(string conversationId, string text)
{
var request = new CreateResponse { Model = "test" };
request.Conversation = BinaryData.FromString($"\"{conversationId}\"");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_" + Guid.NewGuid().ToString("N")[..8], status = "completed", role = "user",
content = new[] { new { type = "input_text", text } } }
});
return request;
}
private static ResponseContext NewServingContext(string responseId, IReadOnlyList<OutputItem> history)
{
var ctx = new Mock<ResponseContext>(responseId) { CallBase = true };
ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", null));
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>())).ReturnsAsync(history);
ctx.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>())).ReturnsAsync(Array.Empty<Item>());
return ctx.Object;
}
#endregion
#region Chat history source routing
// These tests pin down who supplies the conversation history to a hosted agent. Three of them are
// regression tests for the behaviour this region replaced: the handler used to fetch the platform
// history and prepend it to the input of every turn, while a ChatClientAgent independently ran its
// own ChatHistoryProvider. Against that older handler these three fail:
// - DoesNotCopyPlatformHistoryIntoTheSession (the service's turns ended up in the session)
// - DoesNotAskItToStorePlatformHistory (and in a custom provider's own database)
// - UsesThatProviderInsteadOfThePlatform (both sources reached the model at once)
[Fact]
public async Task CreateAsync_AgentWithoutProviderPipeline_ReceivesPlatformHistoryInInputAsync()
{
// Arrange: a plain AIAgent (a hosted workflow, for example) has no ChatHistoryProvider
// pipeline, so the handler is the only thing that can hand it the platform history.
var agent = new CapturingAgent();
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
var (request, ctx) = BuildChainRequest("resp_" + new string('1', 46), callId: null);
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "earlier turn")]);
// Act
await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None));
// Assert
Assert.NotNull(agent.CapturedMessages);
Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("earlier turn", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_SendsPlatformHistoryExactlyOnceAsync()
{
// Arrange: no chat history provider was supplied, so the platform stays the source and the
// handler registers FoundryChatHistoryProvider for the turn.
var captured = new List<ChatMessage>();
var agent = new ChatClientAgent(CreateCapturingChatClient(captured));
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
var (request, ctx) = BuildChainRequest("resp_" + new string('2', 46), callId: null);
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "earlier turn")]);
// Act
await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None));
// Assert: the earlier turn still reaches the model, and only one copy of it does.
Assert.Single(captured, m => m.Text.Contains("earlier turn", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_ChatClientAgentWithHistoryProvider_DoesNotAskItToStorePlatformHistoryAsync()
{
// Arrange: an agent whose own provider records everything it is asked to store, and a platform
// that already holds an earlier turn of this conversation.
var recordingProvider = new RecordingChatHistoryProvider();
var agent = new ChatClientAgent(
CreateCapturingChatClient([]),
new ChatClientAgentOptions { ChatHistoryProvider = recordingProvider });
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
var (request, ctx) = BuildChainRequest("resp_" + new string('5', 46), callId: null);
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "already kept by the service")]);
// Act
await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None));
// Assert: the agent's own store must not be told to write a turn the service already holds. The
// older handler passed that turn in as ordinary input, and since platform items carry no
// chat-history source marker the provider took it for newly written content and stored it,
// duplicating into the agent's own database a conversation the service was already keeping.
Assert.DoesNotContain(recordingProvider.Stored, m => m.Text.Contains("already kept by the service", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_DoesNotCopyPlatformHistoryIntoTheSessionAsync()
{
// Arrange: the model inside the container keeps the conversation, so it reports a conversation
// id of its own, and the platform reports one earlier turn for the same conversation.
const string ResponseId = "resp_" + "4444444444444444444444444444444444444444444444";
var store = new InMemoryAgentSessionStore();
var agent = new ChatClientAgent(CreateCapturingChatClient([], conversationId: "conv-model"));
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store);
var (request, ctx) = BuildChainRequest(ResponseId, callId: null);
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "already kept by the service")]);
// Act
await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None));
// Assert: the model and the service are both keeping this conversation, so the container keeps
// none of it. The older handler fed the service's history to the agent as ordinary input, and
// because platform items carry no chat-history source marker the agent's default in-memory
// provider stored it as if this turn had produced it, leaving a third copy on disk that then
// drifts from the other two.
Assert.DoesNotContain("already kept by the service", await SerializedSessionOfAsync(agent, store, ResponseId), StringComparison.Ordinal);
}
[Fact]
public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProviderInsteadOfThePlatformAsync()
{
// Arrange: the agent was created with its own chat history provider.
var captured = new List<ChatMessage>();
var agent = new ChatClientAgent(
CreateCapturingChatClient(captured),
new ChatClientAgentOptions { ChatHistoryProvider = new FixedChatHistoryProvider("from my own store") });
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
var (request, ctx) = BuildChainRequest("resp_" + new string('3', 46), callId: null);
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "from the platform")]);
// Act
await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None));
// Assert: one source only, and hosted it is the one the AgentServer SDK's storage provider
// records and serves back. A provider storing a second copy inside the container would add a
// conversation that storage provider never sees, so the agent's provider is stood down for the
// turn rather than mixed in.
Assert.Contains(captured, m => m.Text.Contains("from the platform", StringComparison.Ordinal));
Assert.DoesNotContain(captured, m => m.Text.Contains("from my own store", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_SessionIsGone_RecoversTheHistoryFromTheServiceAsync()
{
// Arrange: a turn lands on a container that has no session for the conversation, which is what a
// restart or a second replica looks like.
var captured = new List<ChatMessage>();
var agent = new ChatClientAgent(CreateCapturingChatClient(captured));
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
// Act
await DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-cold", "second question", store: true),
NewContextServing("resp_" + new string('9', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]),
CancellationToken.None));
// Assert: nothing inside the container remembers this conversation, and nothing needs to. The
// AgentServer SDK's storage provider holds it and hands it back, so the turn runs as if the
// container had served every one before it.
Assert.Single(captured, m => m.Text.Contains("first question", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_UnstoredRequestAndAgentWithARawRepresentationFactory_KeepsBothAsync()
{
// Arrange: an agent whose own ChatOptions carry a raw representation factory, the way a container
// adds settings the chat client only understands in its own request type. The caller asks for a
// turn the service must not store.
ChatOptions? sentToTheClient = null;
var client = new Mock<IChatClient>();
client.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> _, ChatOptions? options, CancellationToken _) =>
{
sentToTheClient = options;
return ToAsyncEnumerableUpdatesAsync(new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1" });
});
var agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions { EndUserId = "set by the container" },
},
});
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
// Act
await DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-raw", "a question", store: false),
NewContextServing("resp_" + new string('7', 46), []),
CancellationToken.None));
// Assert: the agent chains a request factory with its own by taking the agent's only when the
// request's returns null, so a request factory that always answers would silently drop whatever
// the container configured. Both settings have to survive on the way to the client.
Assert.NotNull(sentToTheClient?.RawRepresentationFactory);
var raw = Assert.IsType<CreateResponseOptions>(sentToTheClient!.RawRepresentationFactory!(client.Object));
Assert.False(raw.StoredOutputEnabled);
Assert.Equal("set by the container", raw.EndUserId);
}
[Fact]
public async Task CreateAsync_AgentWhoseChatClientReportsAConversationId_IsRejectedAsync()
{
// Arrange: a chat client whose underlying service keeps the conversation and says so on every
// answer, whatever the host asks of it.
var client = new Mock<IChatClient>();
client.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Returns(() => ToAsyncEnumerableUpdatesAsync(
new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1", ConversationId = "conv-downstream" }));
var agent = new ChatClientAgent(client.Object);
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
await DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-rejected", "first question", store: true),
NewContextServing("resp_" + new string('3', 45) + "0", []),
CancellationToken.None));
// Act + Assert: a hosted agent's conversation is recorded by the AgentServer SDK's storage
// provider, so a second one held by the service behind the chat client has no owner and no way
// to stay in step. The next turn is refused as a plain bad request rather than run against a
// conversation nobody can reconcile.
var failure = await Assert.ThrowsAsync<ResponsesApiException>(() => DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-rejected", "second question", store: true),
NewContextServing("resp_" + new string('3', 45) + "1", []),
CancellationToken.None)));
Assert.Equal("service_managed_chat_history_not_supported", failure.Error.Code);
Assert.Equal(400, failure.StatusCode);
}
[Fact]
public async Task CreateAsync_ChatClientAgent_TakesTheWholeConversationFromTheHostingServiceAsync()
{
// Arrange: the AgentServer SDK's storage provider holds the conversation, which is the only
// place it lives.
var captured = new List<ChatMessage>();
var agent = new ChatClientAgent(CreateCapturingChatClient(captured));
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
await DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-single-source", "first question", store: true),
NewContextServing("resp_" + new string('4', 45) + "0", []),
CancellationToken.None));
captured.Clear();
// Act: a second turn, with that storage provider serving the first one back.
await DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-single-source", "second question", store: true),
NewContextServing("resp_" + new string('4', 45) + "1", [NewHistoryMessageItem("msg_hist_1", "first question")]),
CancellationToken.None));
// Assert: what it holds plus this turn's input, each exactly once.
Assert.Single(captured, m => m.Text.Contains("first question", StringComparison.Ordinal));
Assert.Single(captured, m => m.Text.Contains("second question", StringComparison.Ordinal));
}
[Fact]
public async Task CreateAsync_StoredRequest_StillAsksTheChatClientNotToStoreAsync()
{
// Arrange: the caller asks for the turn to be stored.
ChatOptions? sentToTheClient = null;
var client = new Mock<IChatClient>();
client.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> _, ChatOptions? options, CancellationToken _) =>
{
sentToTheClient = options;
return ToAsyncEnumerableUpdatesAsync(new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1" });
});
var agent = new ChatClientAgent(client.Object);
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
// Act
await DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-never-downstream", "a question", store: true),
NewContextServing("resp_" + new string('5', 45) + "0", []),
CancellationToken.None));
// Assert: storing is the AgentServer SDK's job, done by its storage provider around this
// handler. Letting the service behind the chat client store as well writes the same
// conversation twice, in two places that then drift apart.
Assert.NotNull(sentToTheClient?.RawRepresentationFactory);
var raw = Assert.IsType<CreateResponseOptions>(sentToTheClient!.RawRepresentationFactory!(client.Object));
Assert.False(raw.StoredOutputEnabled);
}
[Fact]
public async Task CreateAsync_AgentSpeakingChatCompletions_AlsoAsksItNotToStoreAsync()
{
// Arrange: a container whose chat client speaks Chat Completions rather than Responses, so the
// request it understands is a ChatCompletionOptions.
ChatOptions? sentToTheClient = null;
var client = new Mock<IChatClient>();
client.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> _, ChatOptions? options, CancellationToken _) =>
{
sentToTheClient = options;
return ToAsyncEnumerableUpdatesAsync(new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1" });
});
var agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
RawRepresentationFactory = _ => new ChatCompletionOptions { EndUserId = "set by the container" },
},
});
var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
// Act
await DrainEventsAsync(handler.CreateAsync(
NewConversationRequest("conv-completions", "a question", store: true),
NewContextServing("resp_" + new string('6', 45) + "0", []),
CancellationToken.None));
// Assert: the setting has the same name on both OpenAI request shapes, so a chat client speaking
// either protocol is covered, and what the container configured survives alongside it.
Assert.NotNull(sentToTheClient?.RawRepresentationFactory);
var raw = Assert.IsType<ChatCompletionOptions>(sentToTheClient!.RawRepresentationFactory!(client.Object));
Assert.False(raw.StoredOutputEnabled);
Assert.Equal("set by the container", raw.EndUserId);
}
private static CreateResponse NewConversationRequest(string conversationId, string text, bool store)
{
var request = new CreateResponse { Model = "test", Store = store };
request.Conversation = BinaryData.FromString($"\"{conversationId}\"");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_" + Guid.NewGuid().ToString("N")[..8], status = "completed", role = "user",
content = new[] { new { type = "input_text", text } } }
});
return request;
}
private static ResponseContext NewContextServing(string responseId, IReadOnlyList<OutputItem> history)
{
var ctx = new Mock<ResponseContext>(responseId) { CallBase = true };
ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", null));
ctx.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>())).ReturnsAsync(history);
ctx.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>())).ReturnsAsync(Array.Empty<Item>());
return ctx.Object;
}
/// <summary>Reads back the session the handler persisted for a response and returns it as JSON text.</summary>
private static async Task<string> SerializedSessionOfAsync(AIAgent agent, InMemoryAgentSessionStore store, string responseId)
{
var sessionKey = HostedConversationKey.Resolve(conversationId: null, previousResponseId: null, responseId);
var session = await store.GetSessionAsync(agent, sessionKey!, FakeHostedSessionIsolationKeyProvider.DefaultUserId, CancellationToken.None);
// The handler persists the session at the end of every turn, so a missing one means the turn did
// not get that far and the assertions below would otherwise pass without proving anything.
Assert.NotNull(session);
var serialized = await agent.SerializeSessionAsync(session, cancellationToken: CancellationToken.None);
return serialized.GetRawText();
}
private static OutputItemMessage NewHistoryMessageItem(string id, string text) =>
new(
id: id,
role: MessageRole.Assistant,
content: [new MessageContentOutputTextContent(text, Array.Empty<Annotation>(), Array.Empty<LogProb>())],
status: MessageStatus.Completed);
private static IChatClient CreateCapturingChatClient(List<ChatMessage> captured, string? conversationId = null)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken _) =>
{
captured.AddRange(messages);
// Mirror the MEAI OpenAI adapter, which reports no conversation id for a response the
// service was not asked to store: OpenAIResponsesChatClient sets ChatResponse.ConversationId
// to null whenever CreateResponseOptions.StoredOutputEnabled is false. Without that rule
// here a fake would keep handing back a stored thread the caller opted out of.
var storedOutputDisabled =
options?.RawRepresentationFactory?.Invoke(mock.Object) is CreateResponseOptions { StoredOutputEnabled: false };
return ToAsyncEnumerableUpdatesAsync(
new ChatResponseUpdate(ChatRole.Assistant, "ok")
{
MessageId = "resp_msg_1",
ConversationId = storedOutputDisabled ? null : conversationId,
});
});
return mock.Object;
}
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableUpdatesAsync(params ChatResponseUpdate[] updates)
{
foreach (var update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
/// <summary>A chat history provider that always returns the same message, standing in for one backed by a store.</summary>
private sealed class FixedChatHistoryProvider(string text) : ChatHistoryProvider
{
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new([new ChatMessage(ChatRole.User, text)]);
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) => default;
}
/// <summary>A chat history provider that records everything it is asked to write, standing in for one backed by a database.</summary>
private sealed class RecordingChatHistoryProvider : ChatHistoryProvider
{
public List<ChatMessage> Stored { get; } = [];
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new([]);
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
this.Stored.AddRange(context.RequestMessages);
if (context.ResponseMessages is not null)
{
this.Stored.AddRange(context.ResponseMessages);
}
return default;
}
}
#endregion
private static TestAgent CreateTestAgent(string responseText)
{
return new TestAgent(responseText);
@@ -1410,57 +840,6 @@ public class AgentFrameworkResponseHandlerTests
new(new SimpleAgentSession());
}
/// <summary>
/// Stands in for a hosted workflow: an <see cref="AIAgent"/> whose session type is named the way the
/// real one is, which is how the handler recognises a session that already carries the conversation.
/// </summary>
private sealed class WorkflowLikeAgent : AIAgent
{
public IEnumerable<ChatMessage>? CapturedMessages { get; private set; }
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default)
{
this.CapturedMessages = messages.ToList();
return ToAsyncEnumerableAsync(new AgentResponseUpdate
{
MessageId = "resp_msg_1",
Contents = [new MeaiTextContent("captured")]
});
}
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new WorkflowSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(new WorkflowSession());
}
/// <summary>Carries the name the handler looks for; the real one is internal to its own package.</summary>
private sealed class WorkflowSession : AgentSession
{
}
private sealed class CancellationCheckingAgent : AIAgent
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
@@ -50,33 +50,20 @@ public sealed class FileSystemAgentSessionStoreTests : IDisposable
}
[Fact]
public async Task GetSessionAsync_NoFileOnDisk_ReturnsNullAsync()
public async Task GetSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-1", userId: null);
Assert.Null(session);
Assert.Equal(0, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null);
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsNullAsync()
public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
Directory.CreateDirectory(store.RootDirectory);
@@ -85,8 +72,8 @@ public sealed class FileSystemAgentSessionStoreTests : IDisposable
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-empty", userId: null);
Assert.Null(session);
Assert.Equal(0, agent.CreateCalls);
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
@@ -258,7 +245,7 @@ public sealed class FileSystemAgentSessionStoreTests : IDisposable
var session = await store.GetSessionAsync(agent, "missing-id", userId: null);
Assert.Null(session);
Assert.NotNull(session);
Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory.");
}
@@ -398,11 +385,11 @@ public sealed class FileSystemAgentSessionStoreTests : IDisposable
await store.SaveSessionAsync(agent, "shared-conv", NewSession(), userId: "alice");
// Bob requests the same conversationId. The per-user partition means Bob's path is distinct,
// so the store returns null (no leak), not Alice's persisted state.
// so the store returns a fresh session (no leak), not Alice's persisted state.
var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob");
Assert.Null(bobSession); // no session for Bob under his partition
Assert.Equal(0, agent.CreateCalls); // a plain lookup never creates
Assert.NotNull(bobSession);
Assert.Equal(1, agent.CreateCalls); // fresh session created for Bob
Assert.Equal(0, agent.DeserializeCalls); // Alice's file never deserialized for Bob
}
@@ -105,85 +105,6 @@ public sealed class A2AServerServiceCollectionExtensionsTests
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when an AgentIsolationKeyProvider is registered, task operations
/// use scoped identifiers (DI wiring test).
/// </summary>
[Fact]
public async Task AddA2AServer_WithIsolationKeyProvider_TaskStoreReceivesScopedIdsAsync()
{
// Arrange
const string AgentName = "isolation-wiring-agent";
const string TaskId = "task-1";
const string IsolationKey = "alice";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockTaskStore = new Mock<ITaskStore>();
mockTaskStore
.Setup(s => s.GetTaskAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AgentTask { Id = TaskId, ContextId = $"{IsolationKey}::ctx-1", Status = new global::A2A.TaskStatus { State = TaskState.Completed } });
services.AddKeyedSingleton(AgentName, mockTaskStore.Object);
var mockKeyProvider = new Mock<AgentIsolationKeyProvider>();
mockKeyProvider
.Setup(p => p.GetIsolationKeyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(IsolationKey);
services.AddSingleton(mockKeyProvider.Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
var result = await server.GetTaskAsync(new GetTaskRequest { Id = TaskId });
// Assert - the inner store received the scoped task ID
mockTaskStore.Verify(s => s.GetTaskAsync($"{IsolationKey}::{TaskId}", It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that when an already-wrapped IsolationKeyScopedTaskStore is registered,
/// it is not double-wrapped (no double scoping of task IDs).
/// </summary>
[Fact]
public async Task AddA2AServer_WithAlreadyWrappedTaskStore_DoesNotDoubleWrapAsync()
{
// Arrange
const string AgentName = "no-double-wrap-agent";
const string TaskId = "task-1";
const string IsolationKey = "alice";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockInnerStore = new Mock<ITaskStore>();
mockInnerStore
.Setup(s => s.GetTaskAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AgentTask { Id = TaskId, ContextId = $"{IsolationKey}::ctx-1", Status = new global::A2A.TaskStatus { State = TaskState.Completed } });
var mockKeyProvider = new Mock<AgentIsolationKeyProvider>();
mockKeyProvider
.Setup(p => p.GetIsolationKeyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(IsolationKey);
// Pre-wrap the task store
var wrappedStore = new IsolationKeyScopedTaskStore(mockInnerStore.Object, mockKeyProvider.Object, strict: true);
services.AddKeyedSingleton<ITaskStore>(AgentName, wrappedStore);
services.AddSingleton(mockKeyProvider.Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
var result = await server.GetTaskAsync(new GetTaskRequest { Id = TaskId });
// Assert - only single scoping occurred (not alice::alice::task-1)
mockInnerStore.Verify(s => s.GetTaskAsync($"{IsolationKey}::{TaskId}", It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that when a custom AgentSessionStore is registered, AddA2AServer uses it
/// instead of the default InMemoryAgentSessionStore.
@@ -1,335 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Moq;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="IsolationKeyScopedTaskStore"/> class.
/// </summary>
public sealed class IsolationKeyScopedTaskStoreTests
{
private const string AliceKey = "alice";
private const string BobKey = "bob";
private const string TaskId = "task-001";
/// <summary>
/// Verifies that GetTaskAsync scopes the task ID with the isolation key.
/// </summary>
[Fact]
public async Task GetTaskAsync_ScopesTaskIdWithIsolationKeyAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var keyProvider = CreateKeyProvider(AliceKey);
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
// Act
await store.GetTaskAsync(TaskId);
// Assert
innerStore.Verify(s => s.GetTaskAsync($"{AliceKey}::{TaskId}", It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that SaveTaskAsync scopes the task ID and the persisted ContextId with the isolation key,
/// without mutating the caller's task instance.
/// </summary>
[Fact]
public async Task SaveTaskAsync_ScopesTaskIdAndContextIdWithIsolationKeyAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var keyProvider = CreateKeyProvider(AliceKey);
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
var task = new AgentTask { Id = TaskId, ContextId = "ctx-1" };
// Act
await store.SaveTaskAsync(TaskId, task);
// Assert - both the store key and the persisted ContextId are scoped
innerStore.Verify(s => s.SaveTaskAsync(
$"{AliceKey}::{TaskId}",
It.Is<AgentTask>(t => t.ContextId == $"{AliceKey}::ctx-1"),
It.IsAny<CancellationToken>()), Times.Once);
// Assert - the caller's instance is untouched
Assert.Equal("ctx-1", task.ContextId);
}
/// <summary>
/// Verifies that GetTaskAsync strips the isolation key from the returned task's ContextId.
/// </summary>
[Fact]
public async Task GetTaskAsync_UnscopesContextIdOnReadAsync()
{
// Arrange
var innerStore = new InMemoryTaskStore();
var store = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true);
await store.SaveTaskAsync(TaskId, new AgentTask { Id = TaskId, ContextId = "ctx-1" });
// Act
var result = await store.GetTaskAsync(TaskId);
// Assert - the caller observes the bare ContextId
Assert.NotNull(result);
Assert.Equal("ctx-1", result.ContextId);
}
/// <summary>
/// Verifies that DeleteTaskAsync scopes the task ID with the isolation key.
/// </summary>
[Fact]
public async Task DeleteTaskAsync_ScopesTaskIdWithIsolationKeyAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var keyProvider = CreateKeyProvider(AliceKey);
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
// Act
await store.DeleteTaskAsync(TaskId);
// Assert
innerStore.Verify(s => s.DeleteTaskAsync($"{AliceKey}::{TaskId}", It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that different isolation keys produce different scoped task IDs,
/// preventing cross-tenant task access.
/// </summary>
[Fact]
public async Task GetTaskAsync_DifferentTenantsGetDifferentScopedIdsAsync()
{
// Arrange
var innerStore = new InMemoryTaskStore();
var aliceStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true);
var bobStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(BobKey), strict: true);
var aliceTask = new AgentTask { Id = TaskId, ContextId = "ctx-1" };
// Act - Alice saves a task
await aliceStore.SaveTaskAsync(TaskId, aliceTask);
// Assert - Alice can read it
var aliceResult = await aliceStore.GetTaskAsync(TaskId);
Assert.NotNull(aliceResult);
// Assert - Bob cannot read it (different isolation key → different scoped ID)
var bobResult = await bobStore.GetTaskAsync(TaskId);
Assert.Null(bobResult);
}
/// <summary>
/// Verifies that ListTasksAsync scopes the ContextId filter with the isolation key.
/// </summary>
[Fact]
public async Task ListTasksAsync_ScopesContextIdFilterAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
innerStore
.Setup(s => s.ListTasksAsync(It.IsAny<ListTasksRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ListTasksResponse());
var keyProvider = CreateKeyProvider(AliceKey);
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
var request = new ListTasksRequest { ContextId = "ctx-1" };
// Act
await store.ListTasksAsync(request);
// Assert - ContextId was scoped with the isolation key
innerStore.Verify(s => s.ListTasksAsync(
It.Is<ListTasksRequest>(r => r.ContextId == $"{AliceKey}::ctx-1"),
It.IsAny<CancellationToken>()), Times.Once);
// Assert - original request was not mutated
Assert.Equal("ctx-1", request.ContextId);
}
/// <summary>
/// Verifies that ListTasksAsync does not modify the ContextId filter when it is null.
/// </summary>
[Fact]
public async Task ListTasksAsync_NullContextId_DoesNotScopeFilterAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
innerStore
.Setup(s => s.ListTasksAsync(It.IsAny<ListTasksRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ListTasksResponse());
var keyProvider = CreateKeyProvider(AliceKey);
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
var request = new ListTasksRequest { ContextId = null };
// Act
await store.ListTasksAsync(request);
// Assert - ContextId was not modified
innerStore.Verify(s => s.ListTasksAsync(
It.Is<ListTasksRequest>(r => r.ContextId == null),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that an unfiltered ListTasksAsync only returns tasks belonging to the calling tenant.
/// </summary>
[Fact]
public async Task ListTasksAsync_NoContextIdFilter_ExcludesOtherTenantsTasksAsync()
{
// Arrange
var innerStore = new InMemoryTaskStore();
var aliceStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true);
var bobStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(BobKey), strict: true);
await aliceStore.SaveTaskAsync("alice-task", new AgentTask { Id = "alice-task", ContextId = "ctx-1" });
await bobStore.SaveTaskAsync("bob-task", new AgentTask { Id = "bob-task", ContextId = "ctx-2" });
// Act - Bob lists without any filter
var response = await bobStore.ListTasksAsync(new ListTasksRequest());
// Assert - only Bob's task is returned, with a bare ContextId
var task = Assert.Single(response.Tasks);
Assert.Equal("bob-task", task.Id);
Assert.Equal("ctx-2", task.ContextId);
Assert.Equal(1, response.PageSize);
}
/// <summary>
/// Verifies that filtering by ContextId returns the caller's own tasks, since the persisted
/// ContextId is scoped by the same isolation key as the filter.
/// </summary>
[Fact]
public async Task ListTasksAsync_WithContextIdFilter_ReturnsOwnTasksAsync()
{
// Arrange
var innerStore = new InMemoryTaskStore();
var aliceStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true);
var bobStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(BobKey), strict: true);
await aliceStore.SaveTaskAsync(TaskId, new AgentTask { Id = TaskId, ContextId = "ctx-1" });
// Act
var aliceResponse = await aliceStore.ListTasksAsync(new ListTasksRequest { ContextId = "ctx-1" });
var bobResponse = await bobStore.ListTasksAsync(new ListTasksRequest { ContextId = "ctx-1" });
// Assert - Alice sees her task; Bob sees nothing for the same bare context
var task = Assert.Single(aliceResponse.Tasks);
Assert.Equal("ctx-1", task.ContextId);
Assert.Empty(bobResponse.Tasks);
}
/// <summary>
/// Verifies that strict mode throws when the isolation key provider returns null.
/// </summary>
[Fact]
public async Task GetTaskAsync_StrictMode_NullKey_ThrowsAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var keyProvider = CreateKeyProvider(null);
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => store.GetTaskAsync(TaskId));
}
/// <summary>
/// Verifies that non-strict mode passes through the bare task ID when the key is null.
/// </summary>
[Fact]
public async Task GetTaskAsync_NonStrictMode_NullKey_PassesThroughAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var keyProvider = CreateKeyProvider(null);
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: false);
// Act
await store.GetTaskAsync(TaskId);
// Assert - bare task ID was used (no scoping)
innerStore.Verify(s => s.GetTaskAsync(TaskId, It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that when no key provider is registered and strict is false,
/// the bare task ID is passed through.
/// </summary>
[Fact]
public async Task GetTaskAsync_NoKeyProvider_NonStrict_PassesThroughAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider: null, strict: false);
// Act
await store.GetTaskAsync(TaskId);
// Assert - bare task ID was used
innerStore.Verify(s => s.GetTaskAsync(TaskId, It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that colons in the isolation key are escaped to prevent ID ambiguity.
/// </summary>
[Fact]
public async Task GetTaskAsync_EscapesColonsInIsolationKeyAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var keyProvider = CreateKeyProvider("tenant:sub");
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
// Act
await store.GetTaskAsync(TaskId);
// Assert - colons are escaped
innerStore.Verify(s => s.GetTaskAsync(@"tenant\:sub::" + TaskId, It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that backslashes in the isolation key are escaped to prevent ID ambiguity.
/// </summary>
[Fact]
public async Task GetTaskAsync_EscapesBackslashesInIsolationKeyAsync()
{
// Arrange
var innerStore = new Mock<ITaskStore>();
var keyProvider = CreateKeyProvider(@"domain\user");
var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true);
// Act
await store.GetTaskAsync(TaskId);
// Assert - backslashes are escaped
innerStore.Verify(s => s.GetTaskAsync(@"domain\\user::" + TaskId, It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that the constructor throws when the inner store is null.
/// </summary>
[Fact]
public void Constructor_NullInnerStore_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new IsolationKeyScopedTaskStore(null!, null, strict: false));
}
private static AgentIsolationKeyProvider CreateKeyProvider(string? key)
{
var mock = new Mock<AgentIsolationKeyProvider>();
mock.Setup(p => p.GetIsolationKeyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(key);
return mock.Object;
}
}
@@ -9,9 +9,9 @@ using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="ClaimsIdentityAgentIsolationKeyProvider"/>.
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentityAgentIsolationKeyProviderTests
public class ClaimsIdentitySessionIsolationKeyProviderTests
{
private const string TestUserId = "test-user-id";
private const string CustomClaimType = "custom-claim-type";
@@ -21,9 +21,9 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentityAgentIsolationKeyProviderTests"/> class.
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
/// </summary>
public ClaimsIdentityAgentIsolationKeyProviderTests()
public ClaimsIdentitySessionIsolationKeyProviderTests()
{
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
}
@@ -37,7 +37,7 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
public void UsesDefaultOptionsWhenNull()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
Assert.NotNull(provider);
}
@@ -48,7 +48,7 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentityAgentIsolationKeyProvider(httpContextAccessor: null);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
Assert.NotNull(provider);
}
@@ -60,9 +60,9 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
{
// Act & Assert
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
new ClaimsIdentityAgentIsolationKeyProvider(
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = null! }));
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
}
/// <summary>
@@ -73,9 +73,9 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentityAgentIsolationKeyProvider(
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = string.Empty }));
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
}
/// <summary>
@@ -86,27 +86,27 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentityAgentIsolationKeyProvider(
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = " " }));
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
}
#endregion
#region GetIsolationKeyAsync Tests
#region GetSessionIsolationKeyAsync Tests
/// <summary>
/// Verify that GetIsolationKeyAsync extracts the claim value from the default claim type.
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, TestUserId);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(TestUserId, result);
@@ -114,54 +114,54 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
/// <summary>
/// Verify that the default claim type is the stable, unique NameIdentifier claim rather than the
/// non-unique display name claim. This guards against the resource-isolation collision described in
/// non-unique display name claim. This guards against the session-isolation collision described in
/// the security report where two principals sharing the same name claim received the same key.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
public async Task GetSessionIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
{
// Arrange - only a display-name claim is present; the default provider must not use it.
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetIsolationKeyAsync uses custom claim type when specified.
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncUsesCustomClaimTypeAsync()
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = CustomClaimType });
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(CustomClaimValue, result);
}
/// <summary>
/// Verify that GetIsolationKeyAsync returns null when the specified claim is missing.
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
{
// Arrange
this.SetupHttpContextWithClaim("other-claim", "value");
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
@@ -171,14 +171,14 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
/// Verify behavior when HttpContextAccessor returns null HttpContext.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
{
// Arrange
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
@@ -188,23 +188,23 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
/// Verify behavior when HttpContextAccessor itself is null.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
{
// Arrange
var provider = new ClaimsIdentityAgentIsolationKeyProvider(httpContextAccessor: null);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetIsolationKeyAsync returns the first matching claim when multiple exist.
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
{
// Arrange
const string FirstValue = "first-value";
@@ -223,39 +223,39 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
};
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(FirstValue, result);
}
/// <summary>
/// Verify that GetIsolationKeyAsync handles empty claim values.
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncHandlesEmptyClaimValueAsync()
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(string.Empty, result);
}
/// <summary>
/// Regression test for the resource-isolation collision security report: two distinct authenticated
/// Regression test for the session-isolation collision security report: two distinct authenticated
/// principals that share the same display-name claim but have different stable identifiers and tenants
/// must produce distinct isolation keys under the default options.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
public async Task GetSessionIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
{
// Arrange - both principals share the same name claim but differ by NameIdentifier and tenant.
const string CommonName = "John Doe";
@@ -270,14 +270,14 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
new Claim(ClaimTypes.NameIdentifier, "oid-user-b"),
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-b"));
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalA });
string? principalAKey = await provider.GetIsolationKeyAsync();
string? principalAKey = await provider.GetSessionIsolationKeyAsync();
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalB });
string? principalBKey = await provider.GetIsolationKeyAsync();
string? principalBKey = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal("oid-user-a", principalAKey);
@@ -286,12 +286,12 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
}
/// <summary>
/// Verify that GetIsolationKeyAsync returns null when the request's user is not authenticated,
/// Verify that GetSessionIsolationKeyAsync returns null when the request's user is not authenticated,
/// even if a claim of the configured type is present. The provider must not derive an isolation key
/// from claims on an unauthenticated identity.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
{
// Arrange - identity has the claim but no authentication type, so IsAuthenticated is false.
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId) };
@@ -299,10 +299,10 @@ public class ClaimsIdentityAgentIsolationKeyProviderTests
var principal = new ClaimsPrincipal(unauthenticatedIdentity);
var httpContext = new DefaultHttpContext { User = principal };
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.False(unauthenticatedIdentity.IsAuthenticated);
@@ -46,7 +46,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public void RequiresInnerStore()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
// Act & Assert
Assert.Throws<ArgumentNullException>("innerStore", () =>
@@ -60,7 +60,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public void UsesDefaultOptionsWhenNull()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
// Act & Assert - should not throw
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
@@ -78,7 +78,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -100,7 +100,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(null);
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -110,7 +110,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
Assert.Contains("Agent isolation key is required", exception.Message);
Assert.Contains("Session isolation key is required", exception.Message);
}
/// <summary>
@@ -120,7 +120,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(null);
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -145,7 +145,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -166,7 +166,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
var sessionToSave = new TestAgentSession();
@@ -190,7 +190,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(null);
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -201,7 +201,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
Assert.Contains("Agent isolation key is required", exception.Message);
Assert.Contains("Session isolation key is required", exception.Message);
}
/// <summary>
@@ -211,7 +211,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(null);
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -243,7 +243,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
const string KeyWithColon = "key:with:colons";
var provider = new TestAgentIsolationKeyProvider(KeyWithColon);
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -266,7 +266,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
const string KeyWithBackslash = @"domain\key";
var provider = new TestAgentIsolationKeyProvider(KeyWithBackslash);
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -289,7 +289,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
const string KeyWithBoth = @"domain\key:role";
var provider = new TestAgentIsolationKeyProvider(KeyWithBoth);
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -336,12 +336,12 @@ public class IsolationKeyScopedAgentSessionStoreTests
.ReturnsAsync(this._testSession);
// Act - Key 1
var provider1 = new TestAgentIsolationKeyProvider(Key1);
var provider1 = new TestSessionIsolationKeyProvider(Key1);
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Act - Key 2
var provider2 = new TestAgentIsolationKeyProvider(Key2);
var provider2 = new TestSessionIsolationKeyProvider(Key2);
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
@@ -362,7 +362,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -380,7 +380,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
var concreteInnerStore = new ConcreteAgentSessionStore();
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
// Act
@@ -395,18 +395,18 @@ public class IsolationKeyScopedAgentSessionStoreTests
#region Helper Classes
/// <summary>
/// Test implementation of <see cref="AgentIsolationKeyProvider"/> for testing purposes.
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestAgentIsolationKeyProvider : AgentIsolationKeyProvider
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly string? _key;
public TestAgentIsolationKeyProvider(string? key)
public TestSessionIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}
@@ -6,22 +6,22 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="AgentIsolationKeyProvider"/> and its contract.
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
/// </summary>
public class AgentIsolationKeyProviderTests
public class SessionIsolationKeyProviderTests
{
/// <summary>
/// Verify that a concrete provider can return a non-null isolation key.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncReturnsNonNullKeyAsync()
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
{
// Arrange
const string ExpectedKey = "test-key";
var provider = new TestAgentIsolationKeyProvider(ExpectedKey);
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(ExpectedKey, result);
@@ -31,13 +31,13 @@ public class AgentIsolationKeyProviderTests
/// Verify that a concrete provider can return null when no key is available.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
{
// Arrange
var provider = new TestAgentIsolationKeyProvider(null);
var provider = new TestSessionIsolationKeyProvider(null);
// Act
string? result = await provider.GetIsolationKeyAsync();
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
@@ -47,33 +47,33 @@ public class AgentIsolationKeyProviderTests
/// Verify that cancellation token is passed through to the provider implementation.
/// </summary>
[Fact]
public async Task GetIsolationKeyAsyncPassesCancellationTokenAsync()
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
{
// Arrange
var provider = new TestCancellableAgentIsolationKeyProvider();
var provider = new TestCancellableSessionIsolationKeyProvider();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<TaskCanceledException>(
async () => await provider.GetIsolationKeyAsync(cts.Token));
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
}
#region Test Implementations
/// <summary>
/// Test implementation of <see cref="AgentIsolationKeyProvider"/> for testing purposes.
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestAgentIsolationKeyProvider : AgentIsolationKeyProvider
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly string? _key;
public TestAgentIsolationKeyProvider(string? key)
public TestSessionIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}
@@ -82,9 +82,9 @@ public class AgentIsolationKeyProviderTests
/// <summary>
/// Test implementation that respects cancellation tokens.
/// </summary>
private sealed class TestCancellableAgentIsolationKeyProvider : AgentIsolationKeyProvider
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
public override async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
await Task.Delay(1000, cancellationToken);
return "key";
@@ -1,16 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
#if NET
using System.Diagnostics;
#endif
using System.IO;
using System.Linq;
#if NET
using System.Runtime.Versioning;
#endif
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
@@ -636,223 +628,6 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
#if NET
private static bool TryCreateDirectorySymbolicLink(string linkPath, string targetPath)
{
try
{
Directory.CreateSymbolicLink(linkPath, targetPath);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (PlatformNotSupportedException)
{
return false;
}
return Directory.Exists(linkPath)
&& (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0;
}
private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath)
{
try
{
File.CreateSymbolicLink(linkPath, targetPath);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (PlatformNotSupportedException)
{
return false;
}
return File.Exists(linkPath)
&& (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0;
}
private static bool TryCreateDirectoryJunction(string linkPath, string targetPath)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
string commandInterpreter = Environment.GetEnvironmentVariable("COMSPEC") ?? "cmd.exe";
var startInfo = new ProcessStartInfo
{
FileName = commandInterpreter,
Arguments = $"/c mklink /J \"{linkPath}\" \"{targetPath}\"",
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
using Process? process = Process.Start(startInfo);
if (process is null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0
&& Directory.Exists(linkPath)
&& (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0;
}
[Fact]
public async Task GetSkillsAsync_SymlinkedSkillDirectory_SkipsLinkedSkillAsync()
{
// Arrange
string root = Path.Combine(this._testRoot, "root");
string outsideSkill = Path.Combine(this._testRoot, "outside", "evil-skill");
string linkedSkill = Path.Combine(root, "evil-skill");
Directory.CreateDirectory(root);
Directory.CreateDirectory(outsideSkill);
File.WriteAllText(
Path.Combine(outsideSkill, "SKILL.md"),
"---\nname: evil-skill\ndescription: Linked skill\n---\nBody.");
_ = CreateSkillDirectory(root, "good-skill");
if (!TryCreateDirectorySymbolicLink(linkedSkill, outsideSkill))
{
return;
}
try
{
var source = new AgentFileSkillsSource(root, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Single(skills);
Assert.Equal("good-skill", skills[0].Frontmatter.Name);
}
finally
{
Directory.Delete(linkedSkill);
}
}
[Fact]
public async Task GetSkillsAsync_SymlinkedSkillFile_SkipsSkillAsync()
{
// Arrange
string root = Path.Combine(this._testRoot, "root");
string skillDirectory = Path.Combine(root, "evil-skill");
string outsideSkillFile = Path.Combine(this._testRoot, "outside-SKILL.md");
string linkedSkillFile = Path.Combine(skillDirectory, "SKILL.md");
Directory.CreateDirectory(skillDirectory);
File.WriteAllText(
outsideSkillFile,
"---\nname: evil-skill\ndescription: Linked skill file\n---\nBody.");
if (!TryCreateFileSymbolicLink(linkedSkillFile, outsideSkillFile))
{
return;
}
try
{
var source = new AgentFileSkillsSource(root, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
finally
{
File.Delete(linkedSkillFile);
}
}
[Fact]
public async Task GetSkillsAsync_ConfiguredRootIsSymlink_DiscoversRealSkillsAsync()
{
// Arrange
string realRoot = Path.Combine(this._testRoot, "real-root");
string linkedRoot = Path.Combine(this._testRoot, "linked-root");
_ = CreateSkillDirectory(realRoot, "my-skill");
if (!TryCreateDirectorySymbolicLink(linkedRoot, realRoot))
{
return;
}
try
{
var source = new AgentFileSkillsSource(linkedRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Single(skills);
Assert.Equal("my-skill", skills[0].Frontmatter.Name);
}
finally
{
Directory.Delete(linkedRoot);
}
}
[Fact]
public async Task GetSkillsAsync_JunctionedSkillDirectory_SkipsLinkedSkillAsync()
{
// Arrange
if (!OperatingSystem.IsWindows())
{
return;
}
string root = Path.Combine(this._testRoot, "root");
string outsideSkill = Path.Combine(this._testRoot, "outside", "evil-skill");
string junctionSkill = Path.Combine(root, "evil-skill");
Directory.CreateDirectory(root);
Directory.CreateDirectory(outsideSkill);
File.WriteAllText(
Path.Combine(outsideSkill, "SKILL.md"),
"---\nname: evil-skill\ndescription: Junctioned skill\n---\nBody.");
_ = CreateSkillDirectory(root, "good-skill");
if (!TryCreateDirectoryJunction(junctionSkill, outsideSkill))
{
return;
}
try
{
var source = new AgentFileSkillsSource(root, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Single(skills);
Assert.Equal("good-skill", skills[0].Frontmatter.Name);
}
finally
{
Directory.Delete(junctionSkill);
}
}
[Fact]
public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
{
@@ -1012,111 +787,6 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
#endif
/// <summary>
/// Denies permission to list the contents of <paramref name="path"/> while leaving the
/// directory itself inspectable, so enumerating it fails but reading its attributes does not.
/// Returns <see langword="false"/> when the environment does not honor the restriction
/// (for example, an elevated or root test host), in which case no cleanup is required.
/// </summary>
private static bool TryDenyDirectoryListing(string path, out Action restore)
{
restore = static () => { };
try
{
#if NET
restore = OperatingSystem.IsWindows()
? DenyDirectoryListingOnWindows(path)
: DenyDirectoryListingOnUnix(path);
#else
restore = DenyDirectoryListingOnWindows(path);
#endif
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
return false;
}
// Confirm the restriction actually takes effect; elevated hosts can bypass it.
try
{
_ = Directory.GetDirectories(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return true;
}
restore();
restore = static () => { };
return false;
}
#if NET
[SupportedOSPlatform("windows")]
#endif
private static Action DenyDirectoryListingOnWindows(string path)
{
var directoryInfo = new DirectoryInfo(path);
SecurityIdentifier user = WindowsIdentity.GetCurrent().User!;
var denyRule = new FileSystemAccessRule(user, FileSystemRights.ListDirectory, AccessControlType.Deny);
DirectorySecurity security = directoryInfo.GetAccessControl();
security.AddAccessRule(denyRule);
directoryInfo.SetAccessControl(security);
return () =>
{
DirectorySecurity currentSecurity = directoryInfo.GetAccessControl();
currentSecurity.RemoveAccessRule(denyRule);
directoryInfo.SetAccessControl(currentSecurity);
};
}
#if NET
[UnsupportedOSPlatform("windows")]
private static Action DenyDirectoryListingOnUnix(string path)
{
UnixFileMode originalMode = File.GetUnixFileMode(path);
// Execute-only: the directory can still be traversed and stat'ed, but not listed.
File.SetUnixFileMode(path, UnixFileMode.UserExecute);
return () => File.SetUnixFileMode(path, originalMode);
}
#endif
[Fact]
public async Task GetSkillsAsync_UnreadableSubdirectory_StillDiscoversSiblingSkillsAsync()
{
// Arrange — discovery must not abort when a single subdirectory cannot be enumerated.
string root = Path.Combine(this._testRoot, "root");
string blockedDirectory = Path.Combine(root, "blocked");
Directory.CreateDirectory(blockedDirectory);
_ = CreateSkillDirectory(root, "good-skill");
if (!TryDenyDirectoryListing(blockedDirectory, out Action restore))
{
return;
}
try
{
var source = new AgentFileSkillsSource(root, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Single(skills);
Assert.Equal("good-skill", skills[0].Frontmatter.Name);
}
finally
{
restore();
}
}
[Fact]
public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync()
{
@@ -1427,16 +1097,6 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
return skillDir;
}
private static string CreateSkillDirectory(string root, string name)
{
string skillDirectory = Path.Combine(root, name);
Directory.CreateDirectory(skillDirectory);
File.WriteAllText(
Path.Combine(skillDirectory, "SKILL.md"),
$"---\nname: {name}\ndescription: A skill\n---\nBody.");
return skillDirectory;
}
private string CreateSkillDirectoryWithRawContent(string directoryName, string rawContent)
{
string skillDir = Path.Combine(this._testRoot, directoryName);
@@ -579,67 +579,6 @@ public class ApprovalNotRequiredFunctionBypassingChatClientTests
#endregion
#region Usage Pass-Through Tests
/// <summary>
/// Verifies that usage reported by the inner client is surfaced unchanged, since this decorator
/// makes exactly one inner call and must not drop or alter usage.
/// </summary>
[Fact]
public async Task GetResponseAsync_PassesUsageThroughUnchangedAsync()
{
// Arrange
var usage = new UsageDetails { InputTokenCount = 11, OutputTokenCount = 7, TotalTokenCount = 18 };
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello")]) { Usage = usage }));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var response = await RunWithAgentContextAsync(decorator, session);
// Assert
Assert.NotNull(response.Usage);
Assert.Equal(11, response.Usage!.InputTokenCount);
Assert.Equal(7, response.Usage.OutputTokenCount);
Assert.Equal(18, response.Usage.TotalTokenCount);
}
/// <summary>
/// Verifies that a streaming update carrying both auto-approved approval content and usage content
/// still surfaces its usage after the approval content is stripped.
/// </summary>
[Fact]
public async Task GetStreamingResponseAsync_UpdateWithAutoApprovedAndUsage_StillSurfacesUsageAsync()
{
// Arrange
var noApprovalTool = AIFunctionFactory.Create(() => "result", "plainTool");
var approval = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "plainTool"));
var usage = new UsageDetails { InputTokenCount = 9, OutputTokenCount = 4, TotalTokenCount = 13 };
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(new ChatResponseUpdate(ChatRole.Assistant, [approval, new UsageContent(usage)])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [noApprovalTool] };
// Act
List<ChatResponseUpdate> updates = [];
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — the approval request was bypassed but the usage survived.
Assert.DoesNotContain(updates.SelectMany(static u => u.Contents), static c => c is ToolApprovalRequestContent);
var response = updates.ToChatResponse();
Assert.NotNull(response.Usage);
Assert.Equal(9, response.Usage!.InputTokenCount);
Assert.Equal(4, response.Usage.OutputTokenCount);
Assert.Equal(13, response.Usage.TotalTokenCount);
}
#endregion
#region Helpers
private static async Task<ChatResponse> RunWithAgentContextAsync(
@@ -135,7 +135,6 @@ public class ChatClientAgentOptionsTests
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
DisableApprovalNotRequiredFunctionBypassing = true,
EnableInvocableFunctionBypassing = true,
};
// Act
@@ -153,7 +152,6 @@ public class ChatClientAgentOptionsTests
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
Assert.Equal(original.DisableApprovalNotRequiredFunctionBypassing, clone.DisableApprovalNotRequiredFunctionBypassing);
Assert.Equal(original.EnableInvocableFunctionBypassing, clone.EnableInvocableFunctionBypassing);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);

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