Merge branch 'main' into crickman/feature-compaction

This commit is contained in:
Chris
2026-03-05 02:27:18 -08:00
committed by GitHub
273 changed files with 15647 additions and 3437 deletions
@@ -0,0 +1,48 @@
name: Sample Validation Setup
description: Sets up the environment for sample validation (checkout, Node.js, Copilot CLI, Azure login, Python)
inputs:
azure-client-id:
description: Azure Client ID for OIDC login
required: true
azure-tenant-id:
description: Azure Tenant ID for OIDC login
required: true
azure-subscription-id:
description: Azure Subscription ID for OIDC login
required: true
python-version:
description: The Python version to set up
required: false
default: "3.12"
os:
description: The operating system to set up
required: false
default: "Linux"
runs:
using: "composite"
steps:
- name: Set up Node.js environment
uses: actions/setup-node@v4
- name: Install Copilot CLI
shell: bash
run: npm install -g @github/copilot
- name: Test Copilot CLI
shell: bash
run: copilot -p "What can you do in one sentence?"
- name: Azure CLI Login
uses: azure/login@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 }}
+4 -1
View File
@@ -29,4 +29,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
timeout: 3600
interval: 30
ignored: CodeQL,CodeQL analysis (csharp)
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
# They are outside our control and their transient failures should not block merges.
ignored: CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results
+47 -1
View File
@@ -247,6 +247,51 @@ jobs:
timeout-minutes: 15
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
# Azure Cosmos integration tests
python-tests-cosmos:
name: Python Integration Tests - Cosmos
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
services:
cosmosdb:
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
ports:
- 8081:8081
env:
AZURE_COSMOS_ENDPOINT: "http://localhost:8081/"
# Static Azure Cosmos DB emulator key (documented): https://learn.microsoft.com/en-us/azure/cosmos-db/emulator
AZURE_COSMOS_KEY: "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
AZURE_COSMOS_DATABASE_NAME: "agent-framework-cosmos-it-db"
AZURE_COSMOS_CONTAINER_NAME: "agent-framework-cosmos-it-container"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Wait for Cosmos DB emulator
run: |
for i in {1..60}; do
if curl --silent --show-error http://localhost:8081/ > /dev/null; then
echo "Cosmos DB emulator is ready."
exit 0
fi
sleep 2
done
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
python-integration-tests-check:
if: always()
runs-on: ubuntu-latest
@@ -257,7 +302,8 @@ jobs:
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-azure-ai
python-tests-azure-ai,
python-tests-cosmos
]
steps:
- name: Fail workflow if tests failed
+62
View File
@@ -38,6 +38,7 @@ jobs:
miscChanged: ${{ steps.filter.outputs.misc }}
functionsChanged: ${{ steps.filter.outputs.functions }}
azureAiChanged: ${{ steps.filter.outputs.azure-ai }}
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
@@ -67,6 +68,8 @@ jobs:
- 'python/packages/durabletask/**'
azure-ai:
- 'python/packages/azure-ai/**'
cosmos:
- 'python/packages/azure-cosmos/**'
# run only if 'python' files were changed
- name: python tests
if: steps.filter.outputs.python == 'true'
@@ -390,6 +393,64 @@ jobs:
# TODO: Add python-tests-lab
# Azure Cosmos integration tests
python-tests-cosmos:
name: Python Tests - Cosmos Integration
needs: paths-filter
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true' &&
(github.event_name != 'merge_group' ||
needs.paths-filter.outputs.cosmosChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
services:
cosmosdb:
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
ports:
- 8081:8081
env:
AZURE_COSMOS_ENDPOINT: "http://localhost:8081/"
# Static Azure Cosmos DB emulator key (documented): https://learn.microsoft.com/en-us/azure/cosmos-db/emulator
AZURE_COSMOS_KEY: "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
AZURE_COSMOS_DATABASE_NAME: "agent-framework-cosmos-it-db"
AZURE_COSMOS_CONTAINER_NAME: "agent-framework-cosmos-it-container"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Wait for Cosmos DB emulator
run: |
for i in {1..60}; do
if curl --silent --show-error http://localhost:8081/ > /dev/null; then
echo "Cosmos DB emulator is ready."
exit 0
fi
sleep 2
done
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/**.xml
summary: true
display-options: fEX
fail-on-empty: false
title: Cosmos integration test results
python-integration-tests-check:
if: always()
runs-on: ubuntu-latest
@@ -401,6 +462,7 @@ jobs:
python-tests-misc-integration,
python-tests-functions,
python-tests-azure-ai,
python-tests-cosmos,
]
steps:
- name: Fail workflow if tests failed
+112 -114
View File
@@ -8,297 +8,295 @@ on:
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: claude-opus-4.6
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
permissions:
contents: read
id-token: write
jobs:
validate-01-get-started:
name: Validate 01-get-started
runs-on: ubuntu-latest
permissions:
contents: read
environment: integration
env:
# Azure AI configuration for get-started samples
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
# Required configuration for get-started samples
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
python-version: "3.12"
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-01-get-started
path: python/samples/_sample_validation/reports/
path: python/scripts/sample_validation/reports/
validate-02-agents:
name: Validate 02-agents
runs-on: ubuntu-latest
permissions:
contents: read
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI_RESPONSES_MODEL_ID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
# Observability
ENABLE_INSTRUMENTATION: "true"
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
python-version: "3.12"
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 02-agents --save-report --report-name 02-agents
cd scripts && uv run python -m sample_validation --subdir 02-agents --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-02-agents
path: python/samples/_sample_validation/reports/
path: python/scripts/sample_validation/reports/
validate-03-workflows:
name: Validate 03-workflows
runs-on: ubuntu-latest
permissions:
contents: read
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
python-version: "3.12"
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-03-workflows
path: python/samples/_sample_validation/reports/
path: python/scripts/sample_validation/reports/
validate-04-hosting:
name: Validate 04-hosting
if: false # Temporarily disabled because of sample complexity
runs-on: ubuntu-latest
permissions:
contents: read
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# A2A configuration
A2A_AGENT_HOST: http://localhost:5001/
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
python-version: "3.12"
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
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@v4
if: always()
with:
name: validation-report-04-hosting
path: python/samples/_sample_validation/reports/
path: python/scripts/sample_validation/reports/
validate-05-end-to-end:
name: Validate 05-end-to-end
if: false # Temporarily disabled because of sample complexity
runs-on: ubuntu-latest
permissions:
contents: read
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure AI Search (for evaluation samples)
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }}
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
# Evaluation sample
AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
python-version: "3.12"
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-05-end-to-end
path: python/samples/_sample_validation/reports/
path: python/scripts/sample_validation/reports/
validate-autogen-migration:
name: Validate autogen-migration
runs-on: ubuntu-latest
permissions:
contents: read
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
python-version: "3.12"
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
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@v4
if: always()
with:
name: validation-report-autogen-migration
path: python/samples/_sample_validation/reports/
path: python/scripts/sample_validation/reports/
validate-semantic-kernel-migration:
name: Validate semantic-kernel-migration
runs-on: ubuntu-latest
permissions:
contents: read
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI_RESPONSES_MODEL_ID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
# Copilot Studio
COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }}
COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }}
COPILOTSTUDIOAGENT__TENANTID: ${{ secrets.COPILOTSTUDIOAGENT__TENANTID }}
COPILOTSTUDIOAGENT__AGENTAPPID: ${{ secrets.COPILOTSTUDIOAGENT__AGENTAPPID }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
with:
python-version: "3.12"
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-semantic-kernel-migration
path: python/samples/_sample_validation/reports/
path: python/scripts/sample_validation/reports/
+1 -1
View File
@@ -11,7 +11,7 @@ model:
topP: 0.95
connection:
kind: key
apiKey: =Env.OPENAI_APIKEY
apiKey: =Env.OPENAI_API_KEY
outputSchema:
properties:
language:
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -19,8 +19,8 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0-beta.1" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="2.0.0-beta.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
@@ -35,7 +35,7 @@
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="System.ClientModel" Version="1.8.1" />
<PackageVersion Include="System.ClientModel" Version="1.9.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
@@ -58,6 +58,8 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<!-- Microsoft.AspNetCore.* -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
@@ -92,7 +94,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Plugins.OpenApi" Version="1.67.0" />
<!-- Agent SDKs -->
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.23" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
<!-- M365 Agents SDK -->
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
@@ -185,4 +187,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
+6 -1
View File
@@ -83,7 +83,6 @@
<Folder Name="/Samples/02-agents/AgentSkills/">
<File Path="samples/02-agents/AgentSkills/README.md" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
@@ -289,6 +288,12 @@
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/AspNetAgentAuthorization/">
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml" />
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/README.md" />
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj" />
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitignore" />
+4 -4
View File
@@ -2,11 +2,11 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>2</RCNumber>
<RCNumber>3</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260225.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260225.1</PackageVersion>
<GitTag>1.0.0-rc2</GitTag>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260304.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260304.1</PackageVersion>
<GitTag>1.0.0-rc3</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -89,10 +89,10 @@ namespace SampleApp
internal sealed class UserInfoMemory : AIContextProvider
{
private readonly ProviderSessionState<UserInfo> _sessionState;
private IReadOnlyList<string>? _stateKeys;
private readonly IChatClient _chatClient;
public UserInfoMemory(IChatClient chatClient, Func<AgentSession?, UserInfo>? stateInitializer = null)
: base(null, null)
{
this._sessionState = new ProviderSessionState<UserInfo>(
stateInitializer ?? (_ => new UserInfo()),
@@ -100,7 +100,7 @@ namespace SampleApp
this._chatClient = chatClient;
}
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
public UserInfo GetUserInfo(AgentSession session)
=> this._sessionState.GetOrInitializeState(session);
@@ -22,9 +22,6 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM
var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
// --- Agent Setup ---
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions
@@ -1,49 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use Agent Skills with script execution via the hosted code interpreter.
// When FileAgentSkillScriptExecutor.HostedCodeInterpreter() is configured, the agent can load and execute scripts
// from skill resources using the LLM provider's built-in code interpreter.
//
// This sample includes the password-generator skill:
// - A Python script for generating secure passwords
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
// --- Configuration ---
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// --- Skills Provider with Script Execution ---
// Discovers skills and enables script execution via the hosted code interpreter
var skillsProvider = new FileAgentSkillsProvider(
skillPath: Path.Combine(AppContext.BaseDirectory, "skills"),
options: new FileAgentSkillsProviderOptions
{
ScriptExecutor = FileAgentSkillScriptExecutor.HostedCodeInterpreter()
});
// --- Agent Setup ---
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions
{
Name = "SkillsAgent",
ChatOptions = new()
{
Instructions = "You are a helpful assistant that can generate secure passwords.",
},
AIContextProviders = [skillsProvider],
});
// --- Example: Password generation with script execution ---
Console.WriteLine("Example: Generating a password with a skill script");
Console.WriteLine("---------------------------------------------------");
AgentResponse response = await agent.RunAsync("Generate a secure password for my database account.");
Console.WriteLine($"Agent: {response.Text}\n");
@@ -1,72 +0,0 @@
# Script Execution with Code Interpreter
This sample demonstrates how to use **Agent Skills** with **script execution** via the hosted code interpreter.
## What's Different from Step01?
In the [basic skills sample](../Agent_Step01_BasicSkills/), skills only provide instructions and resources as text. This sample adds **script execution** — the agent can load Python scripts from skill resources and execute them using the LLM provider's built-in code interpreter.
This is enabled by configuring `FileAgentSkillScriptExecutor.HostedCodeInterpreter()` on the skills provider options:
```csharp
var skillsProvider = new FileAgentSkillsProvider(
skillPath: Path.Combine(AppContext.BaseDirectory, "skills"),
options: new FileAgentSkillsProviderOptions
{
ScriptExecutor = FileAgentSkillScriptExecutor.HostedCodeInterpreter()
});
```
## Skills Included
### password-generator
Generates secure passwords using a Python script with configurable length and complexity.
- `scripts/generate.py` — Password generation script
- `references/PASSWORD_GUIDELINES.md` — Recommended length and symbol sets by use case
## Project Structure
```
Agent_Step02_ScriptExecutionWithCodeInterpreter/
├── Program.cs
├── Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj
└── skills/
└── password-generator/
├── SKILL.md
├── scripts/
│ └── generate.py
└── references/
└── PASSWORD_GUIDELINES.md
```
## Running the Sample
### Prerequisites
- .NET 10.0 SDK
- Azure OpenAI endpoint with a deployed model that supports code interpreter
### Setup
1. Set environment variables:
```bash
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
```
2. Run the sample:
```bash
dotnet run
```
### Example
The sample asks the agent to generate a secure password. The agent:
1. Loads the password-generator skill
2. Reads the `generate.py` script via `read_skill_resource`
3. Executes the script using the code interpreter with appropriate parameters
4. Returns the generated password
## Learn More
- [Agent Skills Specification](https://agentskills.io/)
- [Step01: Basic Skills](../Agent_Step01_BasicSkills/) — Skills without script execution
- [Microsoft Agent Framework Documentation](../../../../../docs/)
@@ -1,16 +0,0 @@
---
name: password-generator
description: Generate secure passwords using a Python script. Use when asked to create passwords or credentials.
---
# Password Generator
This skill generates secure passwords using a Python script.
## Usage
When the user requests a password:
1. First, review `references/PASSWORD_GUIDELINES.md` to determine the recommended password length and character sets for the user's use case
2. Load `scripts/generate.py` and adjust its parameters (length, character set) based on the guidelines and user's requirements
3. Execute the script
4. Present the generated password clearly
@@ -1,24 +0,0 @@
# Password Generation Guidelines
## General Rules
- Never reuse passwords across services.
- Always use cryptographically secure randomness (e.g., `random.SystemRandom()`).
- Avoid dictionary words, keyboard patterns, and personal information.
## Recommended Settings by Use Case
| Use Case | Min Length | Character Set | Example |
|-----------------------|-----------|----------------------------------------|--------------------------|
| Web account | 16 | Upper + lower + digits + symbols | `G7!kQp@2xM#nW9$z` |
| Database credential | 24 | Upper + lower + digits + symbols | `aR3$vK8!mN2@pQ7&xL5#wY` |
| Wi-Fi / network key | 20 | Upper + lower + digits + symbols | `Ht4&jL9!rP2#mK7@xQ` |
| API key / token | 32 | Upper + lower + digits (no symbols) | `k8Rm3xQ7nW2pL9vT4jH6yA` |
| Encryption passphrase | 32 | Upper + lower + digits + symbols | `Xp4!kR8@mN2#vQ7&jL9$wT` |
## Symbol Sets
- **Standard symbols**: `!@#$%^&*()-_=+`
- **Extended symbols**: `~`{}[]|;:'",.<>?/\`
- **Safe symbols** (URL/shell-safe): `!@#$&*-_=+`
- If the target system restricts symbols, use only the **safe** set.
@@ -1,11 +0,0 @@
# Password generator script
# Usage: Adjust 'length' as needed, then run
import random
import string
length = 16 # desired length
pool = string.ascii_lowercase + string.ascii_uppercase + string.digits + string.punctuation
password = "".join(random.SystemRandom().choice(pool) for _ in range(length))
print(f"Generated password ({length} chars): {password}")
@@ -5,4 +5,3 @@ Samples demonstrating Agent Skills capabilities.
| Sample | Description |
|--------|-------------|
| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources |
| [Agent_Step02_ScriptExecutionWithCodeInterpreter](Agent_Step02_ScriptExecutionWithCodeInterpreter/) | Using Agent Skills with script execution via the hosted code interpreter |
@@ -73,7 +73,7 @@ AIAgent agent = azureOpenAIClient
// We also want to maintain that exclusion here.
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
StorageInputMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
StorageInputRequestMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
}),
});
@@ -80,7 +80,7 @@ AIAgent agent = azureOpenAIClient
// You may choose to persist the TextSearchProvider messages, if you want the search output to be provided to the model in future interactions as well.
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions()
{
StorageInputMessageFilter = msgs => msgs.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider)
StorageInputRequestMessageFilter = msgs => msgs.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider)
})
});
@@ -79,13 +79,13 @@ namespace SampleApp
internal sealed class VectorChatHistoryProvider : ChatHistoryProvider
{
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
private readonly VectorStore _vectorStore;
public VectorChatHistoryProvider(
VectorStore vectorStore,
Func<AgentSession?, State>? stateInitializer = null,
string? stateKey = null)
: base(provideOutputMessageFilter: null, storeInputMessageFilter: null)
{
this._sessionState = new ProviderSessionState<State>(
stateInitializer ?? (_ => new State(Guid.NewGuid().ToString("N"))),
@@ -93,7 +93,7 @@ namespace SampleApp
this._vectorStore = vectorStore ?? throw new ArgumentNullException(nameof(vectorStore));
}
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
public string GetSessionDbKey(AgentSession session)
=> this._sessionState.GetOrInitializeState(session).SessionDbKey;
@@ -49,11 +49,11 @@ AIAgent agent = new AzureOpenAIClient(
""" },
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
// Use StorageInputMessageFilter to provide a custom filter for messages stored in chat history.
// Use StorageInputRequestMessageFilter to provide a custom filter for request messages stored in chat history.
// By default the chat history provider will store all messages, except for those that came from chat history in the first place.
// In this case, we want to also exclude messages that came from AI context providers.
// You may want to store these messages, depending on their content and your requirements.
StorageInputMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
StorageInputRequestMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
}),
// Add multiple AI context providers: one that maintains a todo list and one that provides upcoming calendar entries.
// The agent will call each provider in sequence, accumulating context from each.
@@ -60,7 +60,7 @@ Console.WriteLine();
// Submit the red team run to the service
Console.WriteLine("Submitting red team run...");
RedTeam redTeamRun = await aiProjectClient.RedTeams.CreateAsync(redTeamConfig);
RedTeam redTeamRun = await aiProjectClient.RedTeams.CreateAsync(redTeamConfig, options: null);
Console.WriteLine($"Red team run created: {redTeamRun.Name}");
Console.WriteLine($"Status: {redTeamRun.Status}");
@@ -35,7 +35,7 @@ string userScope = $"user_{Environment.MachineName}";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Create the Memory Search tool configuration
MemorySearchTool memorySearchTool = new(memoryStoreName, userScope)
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope)
{
// Optional: Configure how quickly new memories are indexed (in seconds)
UpdateDelay = 1,
@@ -88,7 +88,9 @@ internal sealed class Program
{
string workflowYaml = File.ReadAllText("MathChat.yaml");
#pragma warning disable AAIP001 // WorkflowAgentDefinition is experimental
WorkflowAgentDefinition workflowAgentDefinition = WorkflowAgentDefinition.FromYaml(workflowYaml);
#pragma warning restore AAIP001
return
await agentClient.CreateAgentAsync(
@@ -72,6 +72,8 @@ public static class Program
await RunWorkflowAsync(
AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 })
.AddParticipants(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client))
.WithName("Translation Round Robin Workflow")
.WithDescription("A workflow where three translation agents take turns responding in a round-robin fashion.")
.Build(),
[new(ChatRole.User, "Hello, world!")]);
break;
@@ -0,0 +1,156 @@
# Auth Client-Server Sample
This sample demonstrates how to authorize AI agents and their tools using OAuth 2.0 scopes. It shows two levels of access control: an endpoint-level scope (`agent.chat`) that gates access to the agent, and tool-level scopes (`expenses.view`, `expenses.approve`) that control what the agent can do on behalf of each user.
While this sample uses Keycloak to avoid complex setup in order to run the sample, Keycloak can easily be replaced with any OIDC compatible provider, including [Microsoft Entra Id](https://www.microsoft.com/security/business/identity-access/microsoft-entra-id).
## Overview
The sample has three components, all launched with a single `docker compose up`:
| Service | Port | Description |
|---------|------|-------------|
| **WebClient** | `http://localhost:8080` | Razor Pages web app with OIDC login and a chat UI that calls the AgentService |
| **AgentService** | `http://localhost:5001` | ASP.NET Minimal API hosting an expense approval agent with scope-authorized tools |
| **Keycloak** | `http://localhost:5002` | OIDC identity provider, auto-provisioned with realm, clients, scopes, and test users |
```
┌──────────────┐ OIDC login ┌───────────┐
│ WebClient │ ◄──────────────────► │ Keycloak │
│ (Razor app) │ (browser flow) │ (Docker) │
│ :8080 │ │ :5002 │
└──────┬───────┘ └─────┬─────┘
│ REST + Bearer token │
▼ │
┌───────────────┐ JWT validation ──────┘
│ AgentService │ ◄──── (jwks from Keycloak)
│ (Minimal API) │
│ :5001 │
└───────────────┘
```
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
## Configuring Environment Variables
The AgentService requires an OpenAI-compatible endpoint. Set these environment variables before running:
```bash
export OPENAI_API_KEY="<your-openai-api-key>"
export OPENAI_MODEL="gpt-4.1-mini"
```
## Running the Sample
### Option 1: Docker Compose (Recommended)
```bash
cd dotnet/samples/05-end-to-end/AspNetAgentAuthorization
docker compose up
```
This starts Keycloak, the AgentService, and the WebClient. Wait for Keycloak to finish importing the realm (you'll see `Running the server` in the logs).
#### Running in GitHub Codespaces
This sample has been built in such a way that it can be run from GitHub Codespaces.
The Agent Framework repository has a C# specific dev container, named "C# (.NET)", that is configured for Codespaces.
When running in Codespaces, the sample auto-detects the environment via
`CODESPACE_NAME` and `GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN` and configures
Keycloak and the web client accordingly. Just make the required ports public:
```bash
# Make Keycloak and WebClient ports publicly accessible
gh codespace ports visibility 5002:public 8080:public -c $CODESPACE_NAME
# Start the containers (Codespaces is auto-detected)
docker compose up
```
Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab) in your browser.
### Option 2: Run Locally
1. Start Keycloak:
```bash
docker compose up keycloak
```
2. In a new terminal, start the AgentService:
```bash
cd Service
dotnet run --urls "http://localhost:5001"
```
3. In another terminal, start the WebClient:
```bash
cd RazorWebClient
dotnet run --urls "http://localhost:8080"
```
## Using the Sample
1. Open `http://localhost:8080` in your browser
2. Click **Login** — you'll be redirected to Keycloak
3. Sign in with one of the pre-configured users:
- **`testuser` / `password`** — can chat, view expenses, and approve expenses (up to €1,000)
- **`viewer` / `password`** — can chat and view expenses, but **cannot approve** them
4. Try asking the agent:
- _"Show me the pending expenses"_ — both users can do this
- _"Approve expense #1"_ — only `testuser` can do this; `viewer` will be denied
- _"Approve expense #3"_ — even `testuser` will be denied (€4,500 exceeds the €1,000 limit)
## Pre-Configured Keycloak Realm
The `keycloak/dev-realm.json` file auto-provisions:
| Resource | Details |
|----------|---------|
| **Realm** | `dev` |
| **Client: agent-service** | Confidential client (the API audience) |
| **Client: web-client** | Public client for the Razor app's OIDC login |
| **Scope: agent.chat** | Required to call the `/chat` endpoint |
| **Scope: expenses.view** | Required to list pending expenses |
| **Scope: expenses.approve** | Required to approve expenses |
| **User: testuser** | Has `agent.chat`, `expenses.view`, and `expenses.approve` scopes |
| **User: viewer** | Has `agent.chat` and `expenses.view` scopes (no approval) |
### Pre-Seeded Expenses
The service starts with five demo expenses:
| # | Description | Amount | Status |
|---|-------------|--------|--------|
| 1 | Conference travel — Berlin | €850 | Pending |
| 2 | Team dinner — Q4 celebration | €320 | Pending |
| 3 | Cloud infrastructure — annual renewal | €4,500 | Pending (over limit) |
| 4 | Office supplies — ergonomic keyboards | €675 | Pending |
| 5 | Client gift baskets — holiday season | €980 | Pending |
Keycloak admin console: `http://localhost:5002` (login: `admin` / `admin`).
## API Endpoints
### POST /chat (requires `agent.chat` scope)
```bash
# Get a token for testuser
TOKEN=$(curl -s -X POST http://localhost:5002/realms/dev/protocol/openid-connect/token \
-d "grant_type=password&client_id=web-client&username=testuser&password=password&scope=openid agent.chat expenses.view expenses.approve" \
| jq -r '.access_token')
# Chat with the agent
curl -X POST http://localhost:5001/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "Show me the pending expenses"}'
```
## Key Concepts Demonstrated
- **Endpoint-Level Authorization** — The `/chat` endpoint requires the `agent.chat` scope, gating access to the agent itself
- **Tool-Level Authorization** — Each agent tool checks its own scope (`expenses.view`, `expenses.approve`) at runtime, so different users have different capabilities within the same chat session
- **Scope-Based Role Mapping** — Keycloak realm roles map to OAuth scopes, allowing administrators to control which users can access which agent capabilities
@@ -0,0 +1,29 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
# Copy solution-level files for restore
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
COPY eng/ eng/
COPY src/Shared/ src/Shared/
COPY samples/Directory.Build.props samples/
# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
RUN touch /CODE_OF_CONDUCT.md
# Copy project file for restore
COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
# Copy everything and build
COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/ samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "RazorWebClient.dll"]
@@ -0,0 +1,35 @@
@page
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@model AspNetAgentAuthorization.RazorWebClient.Pages.ChatModel
@{
Layout = "_Layout";
}
<h1>Chat with the Agent</h1>
<form method="post">
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<input type="text" name="message" value="@Model.Message" placeholder="Type your message..."
style="flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;" />
<button type="submit"
style="padding: 10px 20px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
Send
</button>
</div>
</form>
@if (Model.Error is not null)
{
<div style="background: #fee; border: 1px solid #fcc; border-radius: 4px; padding: 12px; margin-bottom: 12px; color: #c00;">
<strong>Error:</strong> @Model.Error
</div>
}
@if (Model.Reply is not null)
{
<div style="background: #f0f7ff; border: 1px solid #cce0ff; border-radius: 4px; padding: 12px; margin-bottom: 12px;">
<div style="font-size: 12px; color: #666; margin-bottom: 4px;">Agent (responding to @Model.ReplyUser):</div>
<div>@Model.Reply</div>
</div>
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AspNetAgentAuthorization.RazorWebClient.Pages;
public class ChatModel : PageModel
{
private readonly IHttpClientFactory _httpClientFactory;
public ChatModel(IHttpClientFactory httpClientFactory)
{
this._httpClientFactory = httpClientFactory;
}
[BindProperty]
public string? Message { get; set; }
public string? Reply { get; set; }
public string? ReplyUser { get; set; }
public string? Error { get; set; }
public void OnGet()
{
}
public async Task OnPostAsync()
{
if (string.IsNullOrWhiteSpace(this.Message))
{
return;
}
try
{
// Get the access token stored during OIDC login
string? accessToken = await this.HttpContext.GetTokenAsync("access_token");
if (accessToken is null)
{
this.Error = "No access token available. Please log in again.";
return;
}
// Call the AgentService with the Bearer token
var client = this._httpClientFactory.CreateClient("AgentService");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var payload = JsonSerializer.Serialize(new { message = this.Message });
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await client.PostAsync(new Uri("/chat", UriKind.Relative), content);
if (response.IsSuccessStatusCode)
{
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
this.Reply = json.RootElement.GetProperty("reply").GetString();
this.ReplyUser = json.RootElement.GetProperty("user").GetString();
}
else
{
this.Error = response.StatusCode switch
{
System.Net.HttpStatusCode.Unauthorized => "Authentication failed (401). Your session may have expired.",
System.Net.HttpStatusCode.Forbidden => "Access denied (403). Your account does not have the required 'agent.chat' scope.",
_ => $"AgentService returned {(int)response.StatusCode} {response.ReasonPhrase}."
};
}
}
catch (Exception ex)
{
this.Error = $"Failed to contact the AgentService: {ex.Message}";
}
}
}
@@ -0,0 +1,18 @@
@page
@model AspNetAgentAuthorization.RazorWebClient.Pages.IndexModel
@{
Layout = "_Layout";
}
<h1>Welcome</h1>
<p>This sample demonstrates securing an AI agent API with OAuth 2.0 / OpenID Connect.</p>
@if (User.Identity?.IsAuthenticated == true)
{
<p>You are logged in as <strong>@User.Identity.Name</strong>.</p>
<p><a href="/Chat">Go to Chat →</a></p>
}
else
{
<p>Please <a href="/Chat">log in</a> to chat with the agent.</p>
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AspNetAgentAuthorization.RazorWebClient.Pages;
public class IndexModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnGetLogout()
{
return this.SignOut(
new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme);
}
}
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Agent Chat</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
nav { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #ddd; margin-bottom: 20px; }
nav a { text-decoration: none; color: #0066cc; margin-left: 10px; }
.user-info { color: #666; }
.container { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
h1 { color: #333; }
</style>
</head>
<body>
<nav>
<strong>🤖 Auth Agent Chat</strong>
<div>
@if (User.Identity?.IsAuthenticated == true)
{
<span class="user-info">@User.Identity.Name</span>
<a href="/Index?handler=Logout">Logout</a>
}
else
{
<a href="/Chat">Login</a>
}
</div>
</nav>
<div class="container">
@RenderBody()
</div>
</body>
</html>
@@ -0,0 +1,3 @@
@using Microsoft.AspNetCore.Authentication
@namespace AspNetAgentAuthorization.RazorWebClient.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates an OIDC-authenticated Razor Pages web client
// that calls a JWT-secured AI agent REST API.
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
// Persist data protection keys so antiforgery tokens survive container rebuilds
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/app/keys"));
// ---------------------------------------------------------------------------
// Authentication: Cookie + OpenID Connect (Keycloak)
// ---------------------------------------------------------------------------
string authority = builder.Configuration["Auth:Authority"]
?? throw new InvalidOperationException("Auth:Authority is not configured.");
// PublicKeycloakUrl is the browser-facing Keycloak base URL. When the
// web-client runs inside Docker, Authority points to the internal hostname
// (e.g. http://keycloak:8080) for backchannel discovery, while
// PublicKeycloakUrl is what the browser can reach (e.g. http://localhost:5002).
// When running outside Docker, Authority already IS the public URL and
// PublicKeycloakUrl is not needed.
string? publicKeycloakUrl = builder.Configuration["Auth:PublicKeycloakUrl"];
// In Codespaces, override the public URLs with the tunnel endpoints.
string? codespaceName = Environment.GetEnvironmentVariable("CODESPACE_NAME");
string? codespaceDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN");
bool isCodespaces = !string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain);
if (isCodespaces)
{
publicKeycloakUrl = $"https://{codespaceName}-5002.{codespaceDomain}";
}
// Derive the internal base URL from Authority for URL rewriting.
string internalKeycloakBase = new Uri(authority).GetLeftPart(UriPartial.Authority);
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = authority;
options.ClientId = builder.Configuration["Auth:ClientId"]
?? throw new InvalidOperationException("Auth:ClientId is not configured.");
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
// Request scopes so the access token includes them
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("agent.chat");
options.Scope.Add("expenses.view");
options.Scope.Add("expenses.approve");
// For local development with HTTP-only Keycloak
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
// When the web-client is inside Docker, the backchannel Authority uses
// an internal hostname that differs from the browser-facing URL.
// Rewrite the authorization/logout endpoints so the browser is
// redirected to the public Keycloak URL, and disable issuer validation
// because the token issuer (public URL) won't match the discovery
// document issuer (internal URL).
if (publicKeycloakUrl is not null)
{
#pragma warning disable CA5404 // Token issuer validation disabled: backchannel uses internal Docker hostname while tokens are issued via the public URL.
options.TokenValidationParameters.ValidateIssuer = false;
#pragma warning restore CA5404
// The UserInfo endpoint is on the internal URL but the token
// issuer is the public URL — Keycloak rejects the mismatch.
// The ID token already contains all needed claims.
options.GetClaimsFromUserInfoEndpoint = false;
// In Codespaces the tunnel delivers with Host: localhost, so the
// auto-generated redirect_uri is wrong. Override it explicitly.
string? publicWebClientBase = isCodespaces
? $"https://{codespaceName}-8080.{codespaceDomain}"
: null;
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
.Replace(internalKeycloakBase, publicKeycloakUrl);
if (publicWebClientBase is not null)
{
context.ProtocolMessage.RedirectUri = $"{publicWebClientBase}/signin-oidc";
}
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = context =>
{
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
.Replace(internalKeycloakBase, publicKeycloakUrl);
if (publicWebClientBase is not null)
{
context.ProtocolMessage.PostLogoutRedirectUri = $"{publicWebClientBase}/signout-callback-oidc";
}
return Task.CompletedTask;
},
};
}
});
// ---------------------------------------------------------------------------
// HttpClient for calling the AgentService — attaches Bearer token
// ---------------------------------------------------------------------------
builder.Services.AddHttpClient("AgentService", client =>
{
string baseUrl = builder.Configuration["AgentService:BaseUrl"] ?? "http://localhost:5001";
client.BaseAddress = new Uri(baseUrl);
});
WebApplication app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
await app.RunAsync();
@@ -0,0 +1,12 @@
{
"profiles": {
"RazorWebClient": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:58080;http://localhost:8080"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
</ItemGroup>
</Project>
@@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"Authority": "http://localhost:5002/realms/dev",
"ClientId": "web-client"
},
"AgentService": {
"BaseUrl": "http://localhost:5001"
}
}
@@ -0,0 +1,34 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
# Copy solution-level files for restore
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
COPY eng/ eng/
COPY nuget/ nuget/
COPY src/Shared/ src/Shared/
COPY samples/Directory.Build.props samples/
# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
RUN touch /CODE_OF_CONDUCT.md && mkdir -p /dotnet/nuget && cp /repo/nuget/* /dotnet/nuget/
# Copy project files for restore
COPY src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj src/Microsoft.Agents.AI.Abstractions/
COPY src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj src/Microsoft.Agents.AI/
COPY src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj src/Microsoft.Agents.AI.OpenAI/
COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj samples/05-end-to-end/AspNetAgentAuthorization/Service/
RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
# Copy everything and build
COPY src/ src/
COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/ samples/05-end-to-end/AspNetAgentAuthorization/Service/
RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:5001
EXPOSE 5001
ENTRYPOINT ["dotnet", "Service.dll"]
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.ComponentModel;
namespace AspNetAgentAuthorization.Service;
/// <summary>
/// Represents an expense awaiting approval.
/// </summary>
public sealed class Expense
{
public int Id { get; init; }
public string Description { get; init; } = string.Empty;
public decimal Amount { get; init; }
public string Submitter { get; init; } = string.Empty;
public string Status { get; set; } = "Pending";
public string? ApprovedBy { get; set; }
}
/// <summary>
/// Manages expense approvals. Pre-seeded with demo data so there are
/// expenses to review immediately. Uses <see cref="IUserContext"/> to
/// identify the caller and enforce scope-based permissions.
/// </summary>
public sealed class ExpenseService
{
/// <summary>Maximum amount (EUR) that can be approved.</summary>
private const decimal ApprovalLimit = 1000m;
private static readonly ConcurrentDictionary<int, Expense> s_expenses = new(
new Dictionary<int, Expense>
{
[1] = new() { Id = 1, Description = "Conference travel — Berlin", Amount = 850m, Submitter = "Alice" },
[2] = new() { Id = 2, Description = "Team dinner — Q4 celebration", Amount = 320m, Submitter = "Bob" },
[3] = new() { Id = 3, Description = "Cloud infrastructure — annual renewal", Amount = 4500m, Submitter = "Carol" },
[4] = new() { Id = 4, Description = "Office supplies — ergonomic keyboards", Amount = 675m, Submitter = "Dave" },
[5] = new() { Id = 5, Description = "Client gift baskets — holiday season", Amount = 980m, Submitter = "Eve" },
});
private readonly IUserContext _userContext;
public ExpenseService(IUserContext userContext)
{
this._userContext = userContext;
}
/// <summary>
/// Lists all pending expenses awaiting approval.
/// </summary>
[Description("Lists all pending expenses awaiting approval. Requires the expenses.view scope.")]
public string ListPendingExpenses()
{
if (!this._userContext.Scopes.Contains("expenses.view"))
{
return "Access denied. You do not have the expenses.view scope.";
}
var pending = s_expenses.Values
.Where(e => e.Status == "Pending")
.OrderBy(e => e.Id)
.ToList();
if (pending.Count == 0)
{
return "No pending expenses.";
}
return string.Join("\n", pending.Select(e =>
$"#{e.Id}: {e.Description} — €{e.Amount:N2} (submitted by {e.Submitter})"));
}
/// <summary>
/// Approves a pending expense by its ID.
/// </summary>
[Description("Approves a pending expense by its ID. Requires the expenses.approve scope.")]
public string ApproveExpense([Description("The ID of the expense to approve")] int expenseId)
{
if (!this._userContext.Scopes.Contains("expenses.approve"))
{
return "Access denied. You do not have the expenses.approve scope.";
}
if (!s_expenses.TryGetValue(expenseId, out var expense))
{
return $"Expense #{expenseId} not found.";
}
if (expense.Status != "Pending")
{
return $"Expense #{expenseId} has already been approved.";
}
if (expense.Amount > ApprovalLimit)
{
return $"Cannot approve expense #{expenseId} (€{expense.Amount:N2}). " +
$"Amount exceeds the €{ApprovalLimit:N2} approval limit.";
}
expense.Status = "Approved";
expense.ApprovedBy = this._userContext.DisplayName;
return $"Expense #{expenseId} (\"{expense.Description}\", €{expense.Amount:N2}) has been approved.";
}
}
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to authorize AI agent tools using OAuth 2.0
// scopes. The /chat endpoint requires the "agent.chat" scope, and each tool
// checks its own scope (expenses.view, expenses.approve) at runtime.
using System.Security.Claims;
using System.Text.Json.Serialization;
using AspNetAgentAuthorization.Service;
using Microsoft.Agents.AI;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.AI;
using OpenAI;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// ---------------------------------------------------------------------------
// Authentication: JWT Bearer tokens validated against the OIDC provider
// ---------------------------------------------------------------------------
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"]
?? throw new InvalidOperationException("Auth:Authority is not configured.");
options.Audience = builder.Configuration["Auth:Audience"]
?? throw new InvalidOperationException("Auth:Audience is not configured.");
// For local development with HTTP-only Keycloak
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
options.TokenValidationParameters.ValidateAudience = true;
options.TokenValidationParameters.ValidateLifetime = true;
// In Codespaces, tokens are issued with the public tunnel URL as
// issuer (Keycloak sees X-Forwarded-Host from the tunnel) but the
// agent-service discovers Keycloak via the internal Docker hostname.
// Disable issuer validation in development to handle this mismatch.
options.TokenValidationParameters.ValidateIssuer = !builder.Environment.IsDevelopment();
});
// ---------------------------------------------------------------------------
// Authorization: policy requiring the "agent.chat" scope
// ---------------------------------------------------------------------------
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AgentChat", policy =>
policy.RequireAuthenticatedUser()
.RequireAssertion(context =>
{
// Keycloak puts scopes in the "scope" claim (space-delimited)
var scopeClaim = context.User.FindFirstValue("scope");
if (scopeClaim is not null)
{
var scopes = scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (scopes.Contains("agent.chat", StringComparer.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}));
// ---------------------------------------------------------------------------
// Configure JSON serialization
// ---------------------------------------------------------------------------
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(SampleServiceSerializerContext.Default));
// ---------------------------------------------------------------------------
// Create the AI agent with expense approval tools, registered in DI
// ---------------------------------------------------------------------------
string apiKey = builder.Configuration["OPENAI_API_KEY"]
?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable.");
string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IUserContext, KeycloakUserContext>();
builder.Services.AddScoped<ExpenseService>();
builder.Services.AddScoped<AIAgent>(sp =>
{
var expenseService = sp.GetRequiredService<ExpenseService>();
return new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient()
.AsAIAgent(
name: "ExpenseApprovalAgent",
instructions: "You are an expense approval assistant. You can list pending expenses "
+ "and approve them if the user has the required permissions and approval limit. "
+ "Keep responses concise.",
tools:
[
AIFunctionFactory.Create(expenseService.ListPendingExpenses),
AIFunctionFactory.Create(expenseService.ApproveExpense),
]);
});
WebApplication app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// ---------------------------------------------------------------------------
// POST /chat — requires the "agent.chat" scope
// ---------------------------------------------------------------------------
app.MapPost("/chat", [Authorize(Policy = "AgentChat")] async (ChatRequest request, IUserContext userContext, AIAgent agent) =>
{
var response = await agent.RunAsync(request.Message);
return Results.Ok(new ChatResponse(response.Text, userContext.DisplayName));
});
await app.RunAsync();
// ---------------------------------------------------------------------------
// Request / Response models
// ---------------------------------------------------------------------------
internal sealed record ChatRequest(string Message);
internal sealed record ChatResponse(string Reply, string User);
[JsonSerializable(typeof(ChatRequest))]
[JsonSerializable(typeof(ChatResponse))]
internal sealed partial class SampleServiceSerializerContext : JsonSerializerContext;
@@ -0,0 +1,12 @@
{
"profiles": {
"Service": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55001;http://localhost:5001"
}
}
}
@@ -1,28 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
<!-- Copy skills directory to output -->
<ItemGroup>
<None Include="skills\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Security.Claims;
namespace AspNetAgentAuthorization.Service;
/// <summary>
/// Provides the authenticated user's identity for the current request.
/// </summary>
public interface IUserContext
{
/// <summary>Unique identifier for the current user (e.g. the OIDC "sub" claim).</summary>
string UserId { get; }
/// <summary>Login name for the current user.</summary>
string UserName { get; }
/// <summary>Human-readable display name (e.g. "Test User").</summary>
string DisplayName { get; }
/// <summary>OAuth scopes granted in the current access token.</summary>
IReadOnlySet<string> Scopes { get; }
}
/// <summary>
/// Resolves the current user's identity from Keycloak-specific JWT claims.
/// Keycloak uses <c>sub</c> for the user ID, <c>preferred_username</c>
/// for the login name, <c>given_name</c>/<c>family_name</c> for the
/// display name, and <c>scope</c> (space-delimited) for granted scopes.
/// Registered as a scoped service so it is resolved once per request.
/// </summary>
public sealed class KeycloakUserContext : IUserContext
{
public string UserId { get; }
public string UserName { get; }
public string DisplayName { get; }
public IReadOnlySet<string> Scopes { get; }
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
{
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue("sub")
?? "anonymous";
this.UserName = user?.FindFirstValue("preferred_username")
?? user?.FindFirstValue(ClaimTypes.Name)
?? "unknown";
string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
this.DisplayName = (givenName, familyName) switch
{
(not null, not null) => $"{givenName} {familyName}",
(not null, null) => givenName,
(null, not null) => familyName,
_ => this.UserName,
};
string? scopeClaim = user?.FindFirstValue("scope");
this.Scopes = scopeClaim is not null
? new HashSet<string>(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"Authority": "http://localhost:5002/realms/dev",
"Audience": "agent-service"
}
}
@@ -0,0 +1,80 @@
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: auth-keycloak
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HOSTNAME_STRICT=false
- KC_PROXY_HEADERS=xforwarded
volumes:
- ./keycloak/dev-realm.json:/opt/keycloak/data/import/dev-realm.json
command: ["start-dev", "--import-realm"]
ports:
- "5002:8080"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
# One-shot init container that registers the Codespaces redirect URI
# with Keycloak after it becomes healthy. Auto-detects Codespaces via
# CODESPACE_NAME and GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN env vars.
keycloak-init:
image: curlimages/curl:latest
container_name: auth-keycloak-init
environment:
- KEYCLOAK_URL=http://keycloak:8080
- CODESPACE_NAME=${CODESPACE_NAME:-}
- GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
volumes:
- ./keycloak/setup-redirect-uris.sh:/setup-redirect-uris.sh:ro
entrypoint: ["sh", "/setup-redirect-uris.sh"]
depends_on:
keycloak:
condition: service_healthy
agent-service:
build:
context: ../../..
dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile
container_name: auth-agent-service
environment:
- ASPNETCORE_ENVIRONMENT=Development
- Auth__Authority=http://keycloak:8080/realms/dev
- Auth__Audience=agent-service
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4.1-mini}
ports:
- "5001:5001"
depends_on:
keycloak:
condition: service_healthy
web-client:
build:
context: ../../..
dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile
container_name: auth-web-client
environment:
- ASPNETCORE_ENVIRONMENT=Development
- Auth__Authority=http://keycloak:8080/realms/dev
- Auth__PublicKeycloakUrl=http://localhost:5002
- Auth__ClientId=web-client
- AgentService__BaseUrl=http://agent-service:5001
- CODESPACE_NAME=${CODESPACE_NAME:-}
- GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
ports:
- "8080:8080"
volumes:
- web-client-keys:/app/keys
depends_on:
keycloak:
condition: service_healthy
agent-service:
condition: service_started
volumes:
web-client-keys:
@@ -0,0 +1,232 @@
{
"realm": "dev",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"roles": {
"realm": [
{
"name": "agent-chat-user",
"description": "Grants access to the agent.chat scope"
},
{
"name": "expenses-viewer",
"description": "Grants access to the expenses.view scope"
},
{
"name": "expenses-approver",
"description": "Grants access to the expenses.approve scope"
}
]
},
"scopeMappings": [
{
"clientScope": "agent.chat",
"roles": ["agent-chat-user"]
},
{
"clientScope": "expenses.view",
"roles": ["expenses-viewer"]
},
{
"clientScope": "expenses.approve",
"roles": ["expenses-approver"]
}
],
"clientScopes": [
{
"name": "openid",
"description": "OpenID Connect scope",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true"
},
"protocolMappers": [
{
"name": "sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-sub-mapper",
"config": {
"introspection.token.claim": "true",
"access.token.claim": "true"
}
}
]
},
{
"name": "profile",
"description": "OpenID Connect profile scope",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true"
},
"protocolMappers": [
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "username",
"claim.name": "preferred_username",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "given_name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "firstName",
"claim.name": "given_name",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "family_name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "lastName",
"claim.name": "family_name",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
},
{
"name": "email",
"description": "OpenID Connect email scope",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true"
}
},
{
"name": "agent.chat",
"description": "Allows chatting with the agent",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "expenses.view",
"description": "Allows viewing pending expenses",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "expenses.approve",
"description": "Allows approving pending expenses",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "agent-service-audience",
"description": "Adds the agent-service audience to access tokens",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "agent-service-audience-mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "agent-service",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"clients": [
{
"clientId": "agent-service",
"enabled": true,
"publicClient": false,
"secret": "agent-service-secret",
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"standardFlowEnabled": false,
"protocol": "openid-connect"
},
{
"clientId": "web-client",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"fullScopeAllowed": false,
"protocol": "openid-connect",
"redirectUris": [
"http://localhost:8080/*"
],
"webOrigins": [
"http://localhost:8080"
],
"defaultClientScopes": [
"openid",
"profile",
"email",
"agent-service-audience"
],
"optionalClientScopes": [
"agent.chat",
"expenses.view",
"expenses.approve"
]
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"email": "testuser@example.com",
"firstName": "Test",
"lastName": "User",
"realmRoles": ["agent-chat-user", "expenses-viewer", "expenses-approver"],
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
]
},
{
"username": "viewer",
"enabled": true,
"email": "viewer@example.com",
"firstName": "View",
"lastName": "Only",
"realmRoles": ["agent-chat-user", "expenses-viewer"],
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
]
}
]
}
@@ -0,0 +1,50 @@
#!/bin/bash
# Adds an extra redirect URI to the Keycloak web-client configuration.
# Auto-detects GitHub Codespaces via CODESPACE_NAME and
# GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment variables.
set -e
KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080}"
# Auto-detect Codespaces
if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then
WEBCLIENT_PUBLIC_URL="https://${CODESPACE_NAME}-8080.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}"
fi
if [ -z "$WEBCLIENT_PUBLIC_URL" ]; then
echo "Not running in Codespaces — skipping redirect URI setup."
exit 0
fi
echo "Configuring Keycloak redirect URIs for: $WEBCLIENT_PUBLIC_URL"
# Get admin token
TOKEN=$(curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
-d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
if [ -z "$TOKEN" ]; then
echo "ERROR: Failed to get admin token" >&2
exit 1
fi
# Get web-client UUID
CLIENT_UUID=$(curl -sf "$KEYCLOAK_URL/admin/realms/dev/clients?clientId=web-client" \
-H "Authorization: Bearer $TOKEN" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
if [ -z "$CLIENT_UUID" ]; then
echo "ERROR: Failed to find web-client UUID" >&2
exit 1
fi
# Update redirect URIs and web origins
curl -sf -X PUT "$KEYCLOAK_URL/admin/realms/dev/clients/$CLIENT_UUID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"redirectUris\": [\"http://localhost:8080/*\", \"${WEBCLIENT_PUBLIC_URL}/*\"],
\"webOrigins\": [\"http://localhost:8080\", \"${WEBCLIENT_PUBLIC_URL}\"]
}"
echo "Keycloak redirect URIs updated successfully."
@@ -0,0 +1,70 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app -f net10.0
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "AgentThreadAndHITL.dll"]
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates Human-in-the-Loop (HITL) capabilities with thread persistence.
// The agent wraps function tools with ApprovalRequiredAIFunction to require user approval
// before invoking them. Users respond with 'approve' or 'reject' when prompted.
using System.ComponentModel;
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.AgentServer.AgentFramework.Persistence;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
=> $"The weather in {location} is cloudy with a high of 15°C.";
// Create the chat client and agent.
// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation.
// User should reply with 'approve' or 'reject' when prompted.
#pragma warning disable MEAI001 // Type is for evaluation purposes only
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetChatClient(deploymentName)
.AsIChatClient()
.CreateAIAgent(
instructions: "You are a helpful assistant",
tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]
);
#pragma warning restore MEAI001
var threadRepository = new InMemoryAgentThreadRepository(agent);
await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository);
@@ -0,0 +1,46 @@
# What this sample demonstrates
This sample demonstrates Human-in-the-Loop (HITL) capabilities with thread persistence. The agent wraps function tools with `ApprovalRequiredAIFunction` so that every tool invocation requires explicit user approval before execution. Thread state is maintained across requests using `InMemoryAgentThreadRepository`.
Key features:
- Requiring human approval before executing function calls
- Persisting conversation threads across multiple requests
- Approving or rejecting tool invocations at runtime
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
## Prerequisites
Before running this sample, ensure you have:
1. .NET 10 SDK installed
2. An Azure OpenAI endpoint configured
3. A deployment of a chat model (e.g., gpt-4o-mini)
4. Azure CLI installed and authenticated (`az login`)
## Environment Variables
Set the following environment variables:
```powershell
# Replace with your Azure OpenAI endpoint
$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/"
# Optional, defaults to gpt-4o-mini
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
```
## How It Works
The sample uses `ApprovalRequiredAIFunction` to wrap standard AI function tools. When the model decides to call a tool, the wrapper intercepts the invocation and returns a HITL approval request to the caller instead of executing the function immediately.
1. The user sends a message (e.g., "What is the weather in Vancouver?")
2. The model determines a function call is needed and selects the `GetWeather` tool
3. `ApprovalRequiredAIFunction` intercepts the call and returns an approval request containing the function name and arguments
4. The user responds with `approve` or `reject`
5. If approved, the function executes and the model generates a response using the result
6. If rejected, the model generates a response without the function result
Thread persistence is handled by `InMemoryAgentThreadRepository`, which stores conversation history keyed by `conversation.id`. This means the HITL flow works across multiple HTTP requests as long as each request includes the same `conversation.id`.
> **Note:** HITL requires a stable `conversation.id` in every request so the agent can correlate the approval response with the original function call. Use the `run-requests.http` file in this directory to test the full approval flow.
@@ -0,0 +1,28 @@
name: AgentThreadAndHITL
displayName: "Weather Assistant Agent"
description: >
A Weather Assistant Agent that provides weather information and forecasts. It
demonstrates how to use Azure AI AgentServer with Human-in-the-Loop (HITL)
capabilities to get human approval for functional calls.
metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Human-in-the-Loop
template:
kind: hosted
name: AgentThreadAndHITL
protocols:
- protocol: responses
version: v1
environment_variables:
- name: AZURE_OPENAI_ENDPOINT
value: ${AZURE_OPENAI_ENDPOINT}
- name: AZURE_OPENAI_DEPLOYMENT_NAME
value: gpt-4o-mini
resources:
- name: "gpt-4o-mini"
kind: model
id: gpt-4o-mini
@@ -0,0 +1,70 @@
@host = http://localhost:8088
@endpoint = {{host}}/responses
### Health Check
GET {{host}}/readiness
###
# HITL (Human-in-the-Loop) Flow
#
# This sample requires a multi-turn conversation to demonstrate the approval flow:
# 1. Send a request that triggers a tool call (e.g., asking about the weather)
# 2. The agent responds with a function_call named "__hosted_agent_adapter_hitl__"
# containing the call_id and the tool details
# 3. Send a follow-up request with a function_call_output to approve or reject
#
# IMPORTANT: You must use the same conversation.id across all requests in a flow,
# and update the call_id from step 2 into step 3.
###
### Step 1: Send initial request (triggers HITL approval)
# @name initialRequest
POST {{endpoint}}
Content-Type: application/json
{
"input": "What is the weather like in Vancouver?",
"stream": false,
"conversation": {
"id": "conv_test0000000000000000000000000000000000000000000000"
}
}
### Step 2: Approve the function call
# Copy the call_id from the Step 1 response output and replace below.
# The response will contain: "name": "__hosted_agent_adapter_hitl__" with a "call_id" value.
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "function_call_output",
"call_id": "REPLACE_WITH_CALL_ID_FROM_STEP_1",
"output": "approve"
}
],
"stream": false,
"conversation": {
"id": "conv_test0000000000000000000000000000000000000000000000"
}
}
### Step 3 (alternative): Reject the function call
# Use this instead of Step 2 to deny the tool execution.
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "function_call_output",
"call_id": "REPLACE_WITH_CALL_ID_FROM_STEP_1",
"output": "reject"
}
],
"stream": false,
"conversation": {
"id": "conv_test0000000000000000000000000000000000000000000000"
}
}
@@ -8,6 +8,8 @@ Key features:
- Filtering available tools from an MCP server
- Using Azure OpenAI Responses with MCP tools
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
## Prerequisites
Before running this sample, ensure you have:
@@ -0,0 +1,24 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
@@ -0,0 +1,70 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app -f net10.0
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "AgentWithLocalTools.dll"]
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft. All rights reserved.
// Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle.
// Uses Microsoft Agent Framework with Azure AI Foundry.
// Ready for deployment to Foundry Hosted Agent service.
using System.ClientModel.Primitives;
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Project Endpoint: {endpoint}");
Console.WriteLine($"Model Deployment: {deploymentName}");
var seattleHotels = new[]
{
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
};
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
string GetAvailableHotels(
[Description("Check-in date in YYYY-MM-DD format")] string checkInDate,
[Description("Check-out date in YYYY-MM-DD format")] string checkOutDate,
[Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500)
{
try
{
if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn))
{
return "Error parsing check-in date. Please use YYYY-MM-DD format.";
}
if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut))
{
return "Error parsing check-out date. Please use YYYY-MM-DD format.";
}
if (checkOut <= checkIn)
{
return "Error: Check-out date must be after check-in date.";
}
var nights = (checkOut - checkIn).Days;
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
if (availableHotels.Count == 0)
{
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
}
var result = new StringBuilder();
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
result.AppendLine();
foreach (var hotel in availableHotels)
{
var totalCost = hotel.PricePerNight * nights;
result.AppendLine($"**{hotel.Name}**");
result.AppendLine($" Location: {hotel.Location}");
result.AppendLine($" Rating: {hotel.Rating}/5");
result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})");
result.AppendLine();
}
return result.ToString();
}
catch (Exception ex)
{
return $"Error processing request. Details: {ex.Message}";
}
}
var credential = new AzureCliCredential();
AIProjectClient projectClient = new(new Uri(endpoint), credential);
ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!);
if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is null)
{
throw new InvalidOperationException("Failed to get OpenAI endpoint from project connection.");
}
openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}");
Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}");
var chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
.Build();
var agent = new ChatClientAgent(chatClient,
name: "SeattleHotelAgent",
instructions: """
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
When a user asks about hotels in Seattle:
1. Ask for their check-in and check-out dates if not provided
2. Ask about their budget preferences if not mentioned
3. Use the GetAvailableHotels tool to find available options
4. Present the results in a friendly, informative way
5. Offer to help with additional questions about the hotels or Seattle
Be conversational and helpful. If users ask about things outside of Seattle hotels,
politely let them know you specialize in Seattle hotel recommendations.
""",
tools: [AIFunctionFactory.Create(GetAvailableHotels)])
.AsBuilder()
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
.Build();
Console.WriteLine("Seattle Hotel Agent Server running on http://localhost:8088");
await agent.RunAIAgentAsync(telemetrySourceName: "Agents");
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
@@ -0,0 +1,39 @@
# What this sample demonstrates
This sample demonstrates how to build a hosted agent that uses local C# function tools — a key advantage of code-based hosted agents over prompt agents. The agent acts as a Seattle travel assistant with a `GetAvailableHotels` tool that simulates querying a hotel availability API.
Key features:
- Defining local C# functions as agent tools using `AIFunctionFactory`
- Using `AIProjectClient` to discover the OpenAI connection from the Azure AI Foundry project
- Building a `ChatClientAgent` with custom instructions and tools
- Deploying to the Foundry Hosted Agent service
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
## Prerequisites
Before running this sample, ensure you have:
1. .NET 10 SDK installed
2. An Azure AI Foundry Project with a chat model deployed (e.g., gpt-4o-mini)
3. Azure CLI installed and authenticated (`az login`)
## Environment Variables
Set the following environment variables:
```powershell
# Replace with your Azure AI Foundry project endpoint
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name"
# Optional, defaults to gpt-4o-mini
$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## How It Works
1. The agent uses `AIProjectClient` to discover the Azure OpenAI connection from the project endpoint
2. A local C# function `GetAvailableHotels` is registered as a tool using `AIFunctionFactory.Create`
3. When users ask about hotels, the model invokes the local tool to search simulated hotel data
4. The tool filters hotels by price and calculates total costs based on the requested dates
5. Results are returned to the model, which presents them in a conversational format
@@ -0,0 +1,29 @@
name: seattle-hotel-agent
description: >
A travel assistant agent that helps users find hotels in Seattle.
Demonstrates local C# tool execution - a key advantage of code-based
hosted agents over prompt agents.
metadata:
authors:
- Microsoft
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Local Tools
- Travel Assistant
- Hotel Search
template:
name: seattle-hotel-agent
kind: hosted
protocols:
- protocol: responses
version: v1
environment_variables:
- name: AZURE_AI_PROJECT_ENDPOINT
value: ${AZURE_AI_PROJECT_ENDPOINT}
- name: MODEL_DEPLOYMENT_NAME
value: gpt-4o-mini
resources:
- kind: model
id: gpt-4o-mini
name: chat
@@ -0,0 +1,52 @@
@host = http://localhost:8088
@endpoint = {{host}}/responses
### Health Check
GET {{host}}/readiness
### Simple hotel search - budget under $200
POST {{endpoint}}
Content-Type: application/json
{
"input": "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night",
"stream": false
}
### Hotel search with higher budget
POST {{endpoint}}
Content-Type: application/json
{
"input": "Find me hotels in Seattle for March 20-23, 2025 under $250 per night",
"stream": false
}
### Ask for recommendations without dates (agent should ask for clarification)
POST {{endpoint}}
Content-Type: application/json
{
"input": "What hotels do you recommend in Seattle?",
"stream": false
}
### Explicit input format
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "I'm looking for a hotel in Seattle from 2025-04-01 to 2025-04-05, my budget is $150 per night maximum"
}
]
}
],
"stream": false
}
@@ -8,6 +8,8 @@ Key features:
- Managing conversation memory with a rolling window approach
- Citing source documents in AI responses
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
## Prerequisites
Before running this sample, ensure you have:
@@ -0,0 +1,69 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app -f net10.0
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "AgentWithTools.dll"]
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use Foundry tools (MCP and code interpreter)
// with an AI agent hosted using the Azure AI AgentServer SDK.
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
var credential = new AzureCliCredential();
var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.UseFoundryTools(new { type = "mcp", project_connection_id = toolConnectionId }, new { type = "code_interpreter" })
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
.Build();
var agent = new ChatClientAgent(chatClient,
name: "AgentWithTools",
instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation.
IMPORTANT: When the user asks about Microsoft Learn articles or documentation:
1. You MUST use the microsoft_docs_fetch tool to retrieve the actual content
2. Do NOT rely on your training data
3. Always fetch the latest information from the provided URL
Available tools:
- microsoft_docs_fetch: Fetches and converts Microsoft Learn documentation
- microsoft_docs_search: Searches Microsoft/Azure documentation
- microsoft_code_sample_search: Searches for code examples")
.AsBuilder()
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
.Build();
await agent.RunAIAgentAsync(telemetrySourceName: "Agents");
@@ -0,0 +1,45 @@
# What this sample demonstrates
This sample demonstrates how to use Foundry tools with an AI agent via the `UseFoundryTools` extension. The agent is configured with two tool types: an MCP (Model Context Protocol) connection for fetching Microsoft Learn documentation and a code interpreter for running code when needed.
Key features:
- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter
- Connecting to an external MCP tool via a Foundry project connection
- Using `AzureCliCredential` for Azure authentication
- OpenTelemetry instrumentation for both the chat client and agent
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
## Prerequisites
In addition to the common prerequisites:
1. An **Azure AI Foundry project** with a chat model deployed (e.g., `gpt-5.2`, `gpt-4o-mini`)
2. The **Azure AI Developer** role assigned on the Foundry resource (includes the `agents/write` data action required by `UseFoundryTools`)
3. An **MCP tool connection** configured in your Foundry project pointing to `https://learn.microsoft.com/api/mcp`
## Environment Variables
In addition to the common environment variables in the root README:
```powershell
# Your Azure AI Foundry project endpoint (required by UseFoundryTools)
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-resource.services.ai.azure.com/api/projects/your-project"
# Chat model deployment name (defaults to gpt-4o-mini if not set)
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
# The MCP tool connection name (just the name, not the full ARM resource ID)
$env:MCP_TOOL_CONNECTION_ID="SampleMCPTool"
```
## How It Works
1. An `AzureOpenAIClient` is created with `AzureCliCredential` and used to get a chat client
2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types:
- **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities
- **Code interpreter**: Allows the agent to execute code snippets when needed
3. `UseFoundryTools` resolves the connection using `AZURE_AI_PROJECT_ENDPOINT` internally
4. A `ChatClientAgent` is created with instructions guiding it to use the MCP tools for documentation queries
5. The agent is hosted using `RunAIAgentAsync` which exposes the OpenAI Responses-compatible API endpoint
@@ -0,0 +1,31 @@
name: AgentWithTools
displayName: "Agent with Tools"
description: >
An AI agent that uses Foundry tools (MCP and code interpreter) with Azure OpenAI.
The agent can fetch Microsoft Learn documentation and run code when needed.
metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Tools
- MCP
- Code Interpreter
template:
kind: hosted
name: AgentWithTools
protocols:
- protocol: responses
version: v1
environment_variables:
- name: AZURE_OPENAI_ENDPOINT
value: ${AZURE_OPENAI_ENDPOINT}
- name: AZURE_OPENAI_DEPLOYMENT_NAME
value: gpt-4o-mini
- name: MCP_TOOL_CONNECTION_ID
value: ${MCP_TOOL_CONNECTION_ID}
resources:
- name: "gpt-4o-mini"
kind: model
id: gpt-4o-mini
@@ -0,0 +1,30 @@
@host = http://localhost:8088
@endpoint = {{host}}/responses
### Health Check
GET {{host}}/readiness
### Simple string input
POST {{endpoint}}
Content-Type: application/json
{
"input": "Please use the microsoft_docs_fetch tool to fetch and summarize the Microsoft Learn article at https://learn.microsoft.com/azure/ai-services/openai/overview"
}
### Explicit input
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "Please use the microsoft_docs_fetch tool to fetch and summarize the Microsoft Learn article at https://learn.microsoft.com/azure/ai-services/openai/overview"
}
]
}
]
}
@@ -9,6 +9,8 @@ This workflow uses three translation agents:
The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines.
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
## Prerequisites
Before you begin, ensure you have the following prerequisites:
@@ -0,0 +1,125 @@
# Hosted Agent Samples
These samples demonstrate how to build and host AI agents using the [Azure AI AgentServer SDK](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.agentserver.agentframework-readme). Each sample can be run locally and deployed to Microsoft Foundry as a hosted agent.
## Samples
| Sample | Description |
|--------|-------------|
| [`AgentWithTools`](./AgentWithTools/) | Foundry tools (MCP + code interpreter) via `UseFoundryTools` |
| [`AgentWithLocalTools`](./AgentWithLocalTools/) | Local C# function tool execution (Seattle hotel search) |
| [`AgentThreadAndHITL`](./AgentThreadAndHITL/) | Human-in-the-loop with `ApprovalRequiredAIFunction` and thread persistence |
| [`AgentWithHostedMCP`](./AgentWithHostedMCP/) | Hosted MCP server tool (Microsoft Learn search) |
| [`AgentWithTextSearchRag`](./AgentWithTextSearchRag/) | RAG with `TextSearchProvider` (Contoso Outdoors) |
| [`AgentsInWorkflows`](./AgentsInWorkflows/) | Sequential workflow pipeline (translation chain) |
## Common Prerequisites
Before running any sample, ensure you have:
1. **.NET 10 SDK** or later — [Download](https://dotnet.microsoft.com/download/dotnet/10.0)
2. **Azure CLI** installed — [Install guide](https://learn.microsoft.com/cli/azure/install-azure-cli)
3. **Azure OpenAI** or **Azure AI Foundry project** with a chat model deployed (e.g., `gpt-4o-mini`)
### Authenticate with Azure CLI
All samples use `AzureCliCredential` for authentication. Make sure you're logged in:
```powershell
az login
az account show # Verify the correct subscription
```
### Common Environment Variables
Most samples require one or more of these environment variables:
| Variable | Used By | Description |
|----------|---------|-------------|
| `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-4o-mini`) |
| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithTools, AgentWithLocalTools | Azure AI Foundry project endpoint |
| `MCP_TOOL_CONNECTION_ID` | AgentWithTools | Foundry MCP tool connection name |
| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools | Chat model deployment name (defaults to `gpt-4o-mini`) |
See each sample's README for the specific variables required.
## Azure AI Foundry Setup (for samples that use Foundry)
Some samples (`AgentWithTools`, `AgentWithLocalTools`) connect to an Azure AI Foundry project. If you're using these samples, you'll need additional setup.
### Azure AI Developer Role
The `UseFoundryTools` extension requires the **Azure AI Developer** role on the Cognitive Services resource. Even if you created the project, you may not have this role by default.
```powershell
az role assignment create `
--role "Azure AI Developer" `
--assignee "your-email@microsoft.com" `
--scope "/subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.CognitiveServices/accounts/{account-name}"
```
> **Note**: You need **Owner** or **User Access Administrator** permissions on the resource to assign roles. If you don't have this, you may need to request JIT (Just-In-Time) elevated access via [Azure PIM](https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadmigratedresource).
For more details on permissions, see [Azure AI Foundry Permissions](https://aka.ms/FoundryPermissions).
### Creating an MCP Tool Connection
The `AgentWithTools` sample requires an MCP tool connection configured in your Foundry project:
1. Go to the [Azure AI Foundry portal](https://ai.azure.com)
2. Navigate to your project
3. Go to **Connected resources****+ New connection** → **Model Context Protocol tool**
4. Fill in:
- **Name**: `SampleMCPTool` (or any name you prefer)
- **Remote MCP Server endpoint**: `https://learn.microsoft.com/api/mcp`
- **Authentication**: `Unauthenticated`
5. Click **Connect**
The connection **name** (e.g., `SampleMCPTool`) is used as the `MCP_TOOL_CONNECTION_ID` environment variable.
> **Important**: Use only the connection **name**, not the full ARM resource ID.
## Running a Sample
Each sample runs as a standalone hosted agent on `http://localhost:8088/`:
```powershell
cd <sample-directory>
dotnet run
```
### Interacting with the Agent
Each sample includes a `run-requests.http` file for testing with the [VS Code REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) extension, or you can use PowerShell:
```powershell
$body = @{ input = "Your question here" } | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:8088/responses" -Method Post -Body $body -ContentType "application/json"
```
## Deploying to Microsoft Foundry
Each sample includes a `Dockerfile` and `agent.yaml` for deployment. To deploy your agent to Microsoft Foundry, follow the [hosted agents deployment guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents).
## Troubleshooting
### `PermissionDenied` — lacks `agents/write` data action
Assign the **Azure AI Developer** role to your user. See [Azure AI Developer Role](#azure-ai-developer-role) above.
### `Project connection ... was not found`
Make sure `MCP_TOOL_CONNECTION_ID` contains only the connection **name** (e.g., `SampleMCPTool`), not the full ARM resource ID path.
### `AZURE_AI_PROJECT_ENDPOINT must be set`
The `UseFoundryTools` extension requires `AZURE_AI_PROJECT_ENDPOINT`. Set it to your Foundry project endpoint (e.g., `https://your-resource.services.ai.azure.com/api/projects/your-project`).
### Multi-framework error when running `dotnet run`
If you see "Your project targets multiple frameworks", specify the framework:
```powershell
dotnet run --framework net10.0
```
@@ -33,18 +33,25 @@ public abstract class AIContextProvider
{
private static IEnumerable<ChatMessage> DefaultExternalOnlyFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External);
private static IEnumerable<ChatMessage> DefaultNoopFilter(IEnumerable<ChatMessage> messages)
=> messages;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="AIContextProvider"/> class.
/// </summary>
/// <param name="provideInputMessageFilter">An optional filter function to apply to input messages before providing context via <see cref="ProvideAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing context via <see cref="StoreAIContextAsync"/>. If not set, defaults to a no-op filter that includes all response messages.</param>
protected AIContextProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
{
this.ProvideInputMessageFilter = provideInputMessageFilter ?? DefaultExternalOnlyFilter;
this.StoreInputMessageFilter = storeInputMessageFilter ?? DefaultExternalOnlyFilter;
this.StoreInputRequestMessageFilter = storeInputRequestMessageFilter ?? DefaultExternalOnlyFilter;
this.StoreInputResponseMessageFilter = storeInputResponseMessageFilter ?? DefaultNoopFilter;
}
/// <summary>
@@ -55,17 +62,23 @@ public abstract class AIContextProvider
/// <summary>
/// Gets the filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>.
/// </summary>
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StoreInputMessageFilter { get; }
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StoreInputRequestMessageFilter { get; }
/// <summary>
/// Gets the key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// Gets the filter function to apply to response messages before storing context via <see cref="StoreAIContextAsync"/>.
/// </summary>
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StoreInputResponseMessageFilter { get; }
/// <summary>
/// Gets the set of keys used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
/// <remarks>
/// The default value is the name of the concrete type (e.g. <c>"TextSearchProvider"</c>).
/// Implementations may override this to provide a custom key, for example when multiple
/// instances of the same provider type are used in the same session.
/// The default value is a single-element set containing the name of the concrete type (e.g. <c>"TextSearchProvider"</c>).
/// Implementations may override this to provide custom keys, for example when multiple
/// instances of the same provider type are used in the same session, or when a provider
/// stores state under more than one key.
/// </remarks>
public virtual string StateKey => this.GetType().Name;
public virtual IReadOnlyList<string> StateKeys => this._stateKeys ??= [this.GetType().Name];
/// <summary>
/// Called at the start of agent invocation to provide additional context.
@@ -245,8 +258,10 @@ public abstract class AIContextProvider
/// </para>
/// <para>
/// The default implementation of this method skips execution for any invocation failures,
/// filters the request messages using the configured store-input message filter
/// filters the request messages using the configured store-input request message filter
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
/// filters the response messages using the configured store-input response message filter
/// (which defaults to a no-op, so all response messages are processed),
/// and calls <see cref="StoreAIContextAsync"/> to process the invocation results.
/// For most scenarios, overriding <see cref="StoreAIContextAsync"/> is sufficient to process invocation results,
/// while still benefiting from the default error handling and filtering behavior.
@@ -261,7 +276,7 @@ public abstract class AIContextProvider
return default;
}
var subContext = new InvokedContext(context.Agent, context.Session, this.StoreInputMessageFilter(context.RequestMessages), context.ResponseMessages!);
var subContext = new InvokedContext(context.Agent, context.Session, this.StoreInputRequestMessageFilter(context.RequestMessages), this.StoreInputResponseMessageFilter(context.ResponseMessages!));
return this.StoreAIContextAsync(subContext, cancellationToken);
}
@@ -42,32 +42,40 @@ public abstract class ChatHistoryProvider
{
private static IEnumerable<ChatMessage> DefaultExcludeChatHistoryFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory);
private static IEnumerable<ChatMessage> DefaultNoopFilter(IEnumerable<ChatMessage> messages)
=> messages;
private IReadOnlyList<string>? _stateKeys;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? _provideOutputMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storeInputMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storeInputRequestMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storeInputResponseMessageFilter;
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryProvider"/> class.
/// </summary>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to a no-op filter that includes all response messages.</param>
protected ChatHistoryProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
{
this._provideOutputMessageFilter = provideOutputMessageFilter;
this._storeInputMessageFilter = storeInputMessageFilter ?? DefaultExcludeChatHistoryFilter;
this._storeInputRequestMessageFilter = storeInputRequestMessageFilter ?? DefaultExcludeChatHistoryFilter;
this._storeInputResponseMessageFilter = storeInputResponseMessageFilter ?? DefaultNoopFilter;
}
/// <summary>
/// Gets the key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// Gets the set of keys used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
/// <remarks>
/// The default value is the name of the concrete type (e.g. <c>"InMemoryChatHistoryProvider"</c>).
/// Implementations may override this to provide a custom key, for example when multiple
/// instances of the same provider type are used in the same session.
/// The default value is a single-element set containing the name of the concrete type (e.g. <c>"InMemoryChatHistoryProvider"</c>).
/// Implementations may override this to provide custom keys, for example when multiple
/// instances of the same provider type are used in the same session, or when a provider
/// stores state under more than one key.
/// </remarks>
public virtual string StateKey => this.GetType().Name;
public virtual IReadOnlyList<string> StateKeys => this._stateKeys ??= [this.GetType().Name];
/// <summary>
/// Called at the start of agent invocation to provide messages for the next agent invocation.
@@ -216,7 +224,7 @@ public abstract class ChatHistoryProvider
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// <para>
/// The default implementation of this method, skips execution for any invocation failures, filters messages using the optional storage input message filter
/// The default implementation of this method, skips execution for any invocation failures, filters messages using the optional storage input request and response message filters
/// and calls <see cref="StoreChatHistoryAsync"/> to store new chat history messages.
/// For most scenarios, overriding <see cref="StoreChatHistoryAsync"/> is sufficient to store chat history messages, while still benefiting from the default error handling and filtering behavior.
/// However, for scenarios that require more control over error handling or message filtering, overriding this method allows you to directly control the messages that are stored for the invocation.
@@ -229,7 +237,7 @@ public abstract class ChatHistoryProvider
return default;
}
var subContext = new InvokedContext(context.Agent, context.Session, this._storeInputMessageFilter(context.RequestMessages), context.ResponseMessages!);
var subContext = new InvokedContext(context.Agent, context.Session, this._storeInputRequestMessageFilter(context.RequestMessages), this._storeInputResponseMessageFilter(context.ResponseMessages!));
return this.StoreChatHistoryAsync(subContext, cancellationToken);
}
@@ -27,6 +27,7 @@ namespace Microsoft.Agents.AI;
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryChatHistoryProvider"/> class.
@@ -38,7 +39,8 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = null)
: base(
options?.ProvideOutputMessageFilter,
options?.StorageInputMessageFilter)
options?.StorageInputRequestMessageFilter,
options?.StorageInputResponseMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
options?.StateInitializer ?? (_ => new State()),
@@ -49,7 +51,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <summary>
/// Gets the chat reducer used to process or reduce chat messages. If null, no reduction logic will be applied.
@@ -59,7 +59,19 @@ public sealed class InMemoryChatHistoryProviderOptions
/// Depending on your requirements, you could provide a different filter, that also excludes
/// messages from e.g. AI context providers.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputRequestMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to response messages before they are added to storage
/// during <see cref="ChatHistoryProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, no filtering is applied to response messages before they are stored.
/// If you want to avoid persisting certain messages (for example, those with
/// <see cref="AgentRequestMessageSourceType.ChatHistory"/> source type or produced by AI context providers),
/// provide a filter that returns only the messages you want to keep.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputResponseMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to messages produced by this provider
@@ -34,11 +34,13 @@ public abstract class MessageAIContextProvider : AIContextProvider
/// Initializes a new instance of the <see cref="MessageAIContextProvider"/> class.
/// </summary>
/// <param name="provideInputMessageFilter">An optional filter function to apply to input messages before providing messages via <see cref="ProvideMessagesAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to request messages before storing messages via <see cref="AIContextProvider.StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing messages via <see cref="AIContextProvider.StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing messages via <see cref="AIContextProvider.StoreAIContextAsync"/>. If not set, defaults to including all response messages (no filtering).</param>
protected MessageAIContextProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: base(provideInputMessageFilter, storeInputMessageFilter)
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: base(provideInputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
}
@@ -39,7 +39,7 @@ public static partial class AzureAIProjectChatClientExtensions
/// <exception cref="InvalidOperationException">The agent with the specified name was not found.</exception>
/// <remarks>
/// When instantiating a <see cref="ChatClientAgent"/> by using an <see cref="AgentReference"/>, minimal information will be available about the agent in the instance level, and any logic that relies
/// on <see cref="AIAgent.GetService(Type, object?)"/> to retrieve information about the agent like <see cref="AgentVersion" /> will receive <see langword="null"/> as the result.
/// on <see cref="AIAgent.GetService{TService}(object?)"/> to retrieve information about the agent like <see cref="AgentVersion" /> will receive <see langword="null"/> as the result.
/// </remarks>
public static ChatClientAgent AsAIAgent(
this AIProjectClient aiProjectClient,
@@ -191,7 +191,7 @@ public static partial class AzureAIProjectChatClientExtensions
AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, options.Name, cancellationToken).ConfigureAwait(false);
var agentVersion = agentRecord.Versions.Latest;
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true);
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: !options.UseProvidedChatClientAsIs);
return AsChatClientAgent(
aiProjectClient,
@@ -355,28 +355,27 @@ public static partial class AzureAIProjectChatClientExtensions
private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W");
/// <summary>
/// Asynchronously retrieves an agent record by name using the Protocol method with user-agent header.
/// Asynchronously retrieves an agent record by name using the protocol method to inject user-agent headers.
/// </summary>
private static async Task<AgentRecord> GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken)
{
ClientResult protocolResponse = await aiProjectClient.Agents.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false);
var rawResponse = protocolResponse.GetRawResponse();
AgentRecord? result = ModelReaderWriter.Read<AgentRecord>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
return ClientResult.FromOptionalValue(result, rawResponse).Value!
?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
return result ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
}
/// <summary>
/// Asynchronously creates an agent version using the Protocol method with user-agent header.
/// Asynchronously creates an agent version using the protocol method to inject user-agent headers.
/// </summary>
private static async Task<AgentVersion> CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken)
{
using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIProjectsContext.Default));
ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false);
BinaryData serializedOptions = ModelReaderWriter.Write(creationOptions, s_modelWriterOptionsWire, AzureAIProjectsContext.Default);
BinaryContent content = BinaryContent.Create(serializedOptions);
ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, content, foundryFeatures: null, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false);
var rawResponse = protocolResponse.GetRawResponse();
AgentVersion? result = ModelReaderWriter.Read<AgentVersion>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
return ClientResult.FromValue(result, rawResponse).Value!;
return result ?? throw new InvalidOperationException($"Failed to create agent version for agent '{agentName}'.");
}
private static async Task<ChatClientAgent> CreateAIAgentAsync(
@@ -522,21 +521,23 @@ public static partial class AzureAIProjectChatClientExtensions
// Check function tools
foreach (ResponseTool responseTool in definitionTools)
{
if (requireInvocableTools && responseTool is FunctionTool functionTool)
if (responseTool is FunctionTool functionTool)
{
// Check if a tool with the same type and name exists in the provided tools.
// When invocable tools are required, match only AIFunction.
// Always prefer matching AIFunction when available, regardless of requireInvocableTools.
var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => t is AIFunction tf && functionTool.FunctionName == tf.Name);
if (matchingTool is null)
{
(missingTools ??= []).Add($"Function tool: {functionTool.FunctionName}");
}
else
if (matchingTool is not null)
{
(agentTools ??= []).Add(matchingTool!);
continue;
}
if (requireInvocableTools)
{
(missingTools ??= []).Add($"Function tool: {functionTool.FunctionName}");
continue;
}
continue;
}
(agentTools ??= []).Add(responseTool.AsAITool());
@@ -22,6 +22,7 @@ namespace Microsoft.Agents.AI;
public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
{
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
private readonly CosmosClient _cosmosClient;
private readonly Container _container;
private readonly bool _ownsClient;
@@ -87,7 +88,8 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
/// <param name="ownsClient">Whether this instance owns the CosmosClient and should dispose it.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to storing all response messages.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
@@ -98,8 +100,9 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
bool ownsClient = false,
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: base(provideOutputMessageFilter, storeInputMessageFilter)
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: base(provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
Throw.IfNull(stateInitializer),
@@ -112,7 +115,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <summary>
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using a connection string.
@@ -123,7 +126,8 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to storing all response messages.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
@@ -133,8 +137,9 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
Func<AgentSession?, State> stateInitializer,
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputMessageFilter)
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
}
@@ -148,7 +153,8 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to storing all response messages.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
@@ -159,8 +165,9 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
Func<AgentSession?, State> stateInitializer,
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputMessageFilter)
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
}
@@ -32,6 +32,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
private readonly string _contextPrompt;
private readonly string _memoryStoreName;
private readonly int _maxMemories;
@@ -59,7 +60,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider
Func<AgentSession?, State> stateInitializer,
FoundryMemoryProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
: base(options?.SearchInputMessageFilter, options?.StorageInputRequestMessageFilter, options?.StorageInputResponseMessageFilter)
{
Throw.IfNull(client);
Throw.IfNullOrWhitespace(memoryStoreName);
@@ -82,7 +83,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
session =>
@@ -63,5 +63,14 @@ public sealed class FoundryMemoryProviderOptions
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputRequestMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to response messages when determining which messages to
/// extract memories from during <see cref="AIContextProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider does not filter response messages and includes all messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputResponseMessageFilter { get; set; }
}
@@ -346,14 +346,14 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
{
TextContent textContent = new(assistantMessage.Data?.Content ?? string.Empty)
AIContent content = new()
{
RawRepresentation = assistantMessage
};
return new AgentResponseUpdate(ChatRole.Assistant, [textContent])
return new AgentResponseUpdate(ChatRole.Assistant, [content])
{
AgentId = this.Id,
ResponseId = assistantMessage.Data?.MessageId,
@@ -31,6 +31,7 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Create options with properties from the request
@@ -51,9 +52,14 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
};
var options = new ChatClientAgentRunOptions(chatOptions);
// Convert input to chat messages
// Convert input to chat messages, prepending conversation history if available
var messages = new List<ChatMessage>();
if (conversationHistory is not null)
{
messages.AddRange(conversationHistory);
}
foreach (var inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
/// <summary>
/// Converts stored <see cref="ItemResource"/> objects back to <see cref="ChatMessage"/> objects
/// for injecting conversation history into agent execution.
/// </summary>
internal static class ItemResourceConversions
{
/// <summary>
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
/// Only converts message, function call, and function result items. Other item types are skipped.
/// </summary>
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
switch (item)
{
case ResponsesUserMessageItemResource userMsg:
messages.Add(new ChatMessage(ChatRole.User, ConvertContents(userMsg.Content)));
break;
case ResponsesAssistantMessageItemResource assistantMsg:
messages.Add(new ChatMessage(ChatRole.Assistant, ConvertContents(assistantMsg.Content)));
break;
case ResponsesSystemMessageItemResource systemMsg:
messages.Add(new ChatMessage(ChatRole.System, ConvertContents(systemMsg.Content)));
break;
case ResponsesDeveloperMessageItemResource developerMsg:
messages.Add(new ChatMessage(new ChatRole("developer"), ConvertContents(developerMsg.Content)));
break;
case FunctionToolCallItemResource funcCall:
var arguments = ParseArguments(funcCall.Arguments);
messages.Add(new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)
]));
break;
case FunctionToolCallOutputItemResource funcOutput:
messages.Add(new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent(funcOutput.CallId, funcOutput.Output)
]));
break;
// Skip all other item types (reasoning, executor_action, web_search, etc.)
// They are not relevant for conversation context.
}
}
return messages;
}
private static List<AIContent> ConvertContents(List<ItemContent> contents)
{
var result = new List<AIContent>();
foreach (var content in contents)
{
var aiContent = ItemContentConverter.ToAIContent(content);
if (aiContent is not null)
{
result.Add(aiContent);
}
}
return result;
}
private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
{
if (string.IsNullOrEmpty(argumentsJson))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(argumentsJson);
var result = new Dictionary<string, object?>();
foreach (var property in doc.RootElement.EnumerateObject())
{
result[property.Name] = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => property.Value.GetRawText()
};
}
return result;
}
catch (JsonException)
{
return null;
}
}
}
@@ -82,6 +82,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string agentName = GetAgentName(request)!;
@@ -105,6 +106,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
var options = new ChatClientAgentRunOptions(chatOptions);
var messages = new List<ChatMessage>();
if (conversationHistory is not null)
{
messages.AddRange(conversationHistory);
}
foreach (var inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
@@ -28,10 +29,12 @@ internal interface IResponseExecutor
/// </summary>
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
/// <param name="request">The create response request.</param>
/// <param name="conversationHistory">Optional prior conversation messages to prepend to the agent's input.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of streaming response events.</returns>
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
CancellationToken cancellationToken = default);
}
@@ -425,11 +425,28 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
// Create agent invocation context
var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));
// Load conversation history if a conversation ID is provided
IReadOnlyList<Extensions.AI.ChatMessage>? conversationHistory = null;
if (this._conversationStorage is not null && request.Conversation?.Id is not null)
{
var itemsResult = await this._conversationStorage.ListItemsAsync(
request.Conversation.Id,
limit: 100,
order: SortOrder.Ascending,
cancellationToken: linkedCts.Token).ConfigureAwait(false);
var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
if (history.Count > 0)
{
conversationHistory = history;
}
}
// Collect output items for conversation storage
List<ItemResource> outputItems = [];
// Execute using the injected executor
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false))
{
state.AddStreamingEvent(streamingEvent);
@@ -27,6 +27,7 @@ public sealed class Mem0Provider : MessageAIContextProvider
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
private readonly string _contextPrompt;
private readonly bool _enableSensitiveTelemetryData;
@@ -52,7 +53,7 @@ public sealed class Mem0Provider : MessageAIContextProvider
/// </code>
/// </remarks>
public Mem0Provider(HttpClient httpClient, Func<AgentSession?, State> stateInitializer, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
: base(options?.SearchInputMessageFilter, options?.StorageInputRequestMessageFilter, options?.StorageInputResponseMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
ValidateStateInitializer(Throw.IfNull(stateInitializer)),
@@ -72,7 +73,7 @@ public sealed class Mem0Provider : MessageAIContextProvider
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
session =>
@@ -47,5 +47,14 @@ public sealed class Mem0ProviderOptions
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputRequestMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to response messages when determining which messages to
/// extract memories from during <see cref="AIContextProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider applies no filtering and includes all response messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputResponseMessageFilter { get; set; }
}
@@ -16,6 +16,8 @@ public sealed class GroupChatWorkflowBuilder
{
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
private string _name = string.Empty;
private string _description = string.Empty;
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
this._managerFactory = managerFactory;
@@ -42,6 +44,28 @@ public sealed class GroupChatWorkflowBuilder
return this;
}
/// <summary>
/// Sets the human-readable name for the workflow.
/// </summary>
/// <param name="name">The name of the workflow.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <summary>
/// Sets the description for the workflow.
/// </summary>
/// <param name="description">The description of what the workflow does.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via group chat, with the next
/// agent to process messages selected by the group chat manager.
@@ -65,6 +89,16 @@ public sealed class GroupChatWorkflowBuilder
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
WorkflowBuilder builder = new(host);
if (!string.IsNullOrEmpty(this._name))
{
builder = builder.WithName(this._name);
}
if (!string.IsNullOrEmpty(this._description))
{
builder = builder.WithDescription(this._description);
}
foreach (var participant in agentMap.Values)
{
builder
@@ -153,18 +153,52 @@ public static class WorkflowVisualizer
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
{
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
// Build a mapping from raw IDs to Mermaid-safe node aliases that preserve
// as much of the original ID as possible for readability.
// Mermaid node IDs cannot contain spaces, dots, pipes, or most special characters.
var aliasMap = new Dictionary<string, string>();
var usedAliases = new HashSet<string>(StringComparer.Ordinal);
string GetSafeId(string id)
{
var key = ns != null ? $"{ns}/{id}" : id;
if (!aliasMap.TryGetValue(key, out var alias))
{
alias = SanitizeMermaidNodeId(key);
// Handle collisions by appending a numeric suffix
if (!usedAliases.Add(alias))
{
var i = 2;
while (!usedAliases.Add($"{alias}_{i}"))
{
if (i >= 10_000)
{
throw new InvalidOperationException($"Unable to generate a unique Mermaid node ID for '{key}'.");
}
i++;
}
alias = $"{alias}_{i}";
}
aliasMap[key] = alias;
}
return alias;
}
// Add start node
var startExecutorId = workflow.StartExecutorId;
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
lines.Add($"{indent}{GetSafeId(startExecutorId)}[\"{EscapeMermaidLabel(startExecutorId)} (Start)\"];");
// Add other executor nodes
foreach (var executorId in workflow.ExecutorBindings.Keys)
{
if (executorId != startExecutorId)
{
lines.Add($"{indent}{MapId(executorId)}[\"{executorId}\"];");
lines.Add($"{indent}{GetSafeId(executorId)}[\"{EscapeMermaidLabel(executorId)}\"];");
}
}
@@ -175,7 +209,7 @@ public static class WorkflowVisualizer
lines.Add("");
foreach (var (nodeId, _, _) in fanInDescriptors)
{
lines.Add($"{indent}{MapId(nodeId)}((fan-in))");
lines.Add($"{indent}{GetSafeId(nodeId)}((fan-in))");
}
}
@@ -184,9 +218,9 @@ public static class WorkflowVisualizer
{
foreach (var src in sources)
{
lines.Add($"{indent}{MapId(src)} --> {MapId(nodeId)};");
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(nodeId)};");
}
lines.Add($"{indent}{MapId(nodeId)} --> {MapId(target)};");
lines.Add($"{indent}{GetSafeId(nodeId)} --> {GetSafeId(target)};");
}
// Emit normal edges
@@ -197,17 +231,17 @@ public static class WorkflowVisualizer
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
// Conditional edge, with user label or default
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
lines.Add($"{indent}{GetSafeId(src)} -. {effectiveLabel} .-> {GetSafeId(target)};");
}
else if (label != null)
{
// Regular edge with label
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
lines.Add($"{indent}{GetSafeId(src)} -->|{EscapeMermaidLabel(label)}| {GetSafeId(target)};");
}
else
{
// Regular edge without label
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(target)};");
}
}
}
@@ -301,6 +335,50 @@ public static class WorkflowVisualizer
return false;
}
/// <summary>
/// Converts a raw node ID into a Mermaid-safe identifier that preserves as much
/// of the original text as possible. ASCII letters, digits, and underscores are kept
/// as-is (including existing consecutive underscores). All other characters (including
/// non-ASCII letters) are replaced with underscores, with consecutive invalid characters
/// collapsed into a single underscore. A leading digit gets a prefix.
/// </summary>
private static string SanitizeMermaidNodeId(string id)
{
Throw.IfNull(id);
var sb = new StringBuilder(id.Length);
bool lastWasUnderscore = false;
foreach (var ch in id)
{
bool isAsciiSafe = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_';
if (isAsciiSafe)
{
sb.Append(ch);
lastWasUnderscore = ch == '_';
}
else if (!lastWasUnderscore)
{
sb.Append('_');
lastWasUnderscore = true;
}
}
// Trim trailing underscore
while (sb.Length > 0 && sb[sb.Length - 1] == '_')
{
sb.Length--;
}
// Mermaid IDs must not start with a digit
if (sb.Length > 0 && sb[0] >= '0' && sb[0] <= '9')
{
sb.Insert(0, "n_");
}
// Guard against empty result (e.g. id was all special chars)
return sb.Length == 0 ? "node" : sb.ToString();
}
// Helper method to escape special characters in DOT labels
private static string EscapeDotLabel(string label)
{
@@ -12,6 +12,7 @@ namespace Microsoft.Agents.AI.Workflows;
internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
{
private readonly ProviderSessionState<StoreState> _sessionState;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowChatHistoryProvider"/> class.
@@ -22,7 +23,6 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
/// and source generated serializers are required, or Native AOT / Trimming is required.
/// </param>
public WorkflowChatHistoryProvider(JsonSerializerOptions? jsonSerializerOptions = null)
: base(provideOutputMessageFilter: null, storeInputMessageFilter: null)
{
this._sessionState = new ProviderSessionState<StoreState>(
_ => new StoreState(),
@@ -31,7 +31,7 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
internal sealed class StoreState
{
@@ -112,7 +112,7 @@ public sealed partial class ChatClientAgent : AIAgent
this.ChatHistoryProvider = options?.ChatHistoryProvider ?? new InMemoryChatHistoryProvider();
this.AIContextProviders = this._agentOptions?.AIContextProviders as IReadOnlyList<AIContextProvider> ?? this._agentOptions?.AIContextProviders?.ToList();
// Validate that no two providers share the same StateKey, since they would overwrite each other's state in the session.
// Validate that no two providers share any StateKeys, since they would overwrite each other's state in the session.
this._aiContextProviderStateKeys = ValidateAndCollectStateKeys(this._agentOptions?.AIContextProviders, this.ChatHistoryProvider);
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
@@ -747,9 +747,15 @@ public sealed partial class ChatClientAgent : AIAgent
{
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true)
if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true
&& this._logger.IsEnabled(LogLevel.Warning))
{
this._logger.LogAgentChatClientHistoryProviderConflict(nameof(ChatClientAgentSession.ConversationId), nameof(this.ChatHistoryProvider), this.Id, this.GetLoggingAgentName());
var loggingAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientHistoryProviderConflict(
nameof(ChatClientAgentSession.ConversationId),
nameof(this.ChatHistoryProvider),
this.Id,
loggingAgentName);
}
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true)
@@ -824,11 +830,17 @@ public sealed partial class ChatClientAgent : AIAgent
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
}
// Validate that the override provider's StateKey does not clash with any AIContextProvider's StateKey.
if (overrideProvider is not null && this._aiContextProviderStateKeys.Contains(overrideProvider.StateKey))
// Validate that the override provider's StateKeys do not clash with any AIContextProvider's StateKeys.
if (overrideProvider is not null)
{
throw new InvalidOperationException(
$"The ChatHistoryProvider '{overrideProvider.GetType().Name}' uses the state key '{overrideProvider.StateKey}' which is already used by one of the configured AIContextProviders. Each provider must use a unique state key to avoid overwriting each other's state.");
foreach (var key in overrideProvider.StateKeys)
{
if (this._aiContextProviderStateKeys.Contains(key))
{
throw new InvalidOperationException(
$"The ChatHistoryProvider '{overrideProvider.GetType().Name}' uses state key '{key}' which is already used by one of the configured AIContextProviders. Each provider must use unique state keys to avoid overwriting each other's state.");
}
}
}
provider = overrideProvider;
@@ -879,7 +891,7 @@ public sealed partial class ChatClientAgent : AIAgent
private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent";
/// <summary>
/// Validates that all configured providers have unique <see cref="AIContextProvider.StateKey"/> values
/// Validates that all configured providers have unique <see cref="AIContextProvider.StateKeys"/> values
/// and returns a <see cref="HashSet{T}"/> of the AIContextProvider state keys.
/// </summary>
private static HashSet<string> ValidateAndCollectStateKeys(IEnumerable<AIContextProvider>? aiContextProviders, ChatHistoryProvider? chatHistoryProvider)
@@ -890,10 +902,13 @@ public sealed partial class ChatClientAgent : AIAgent
{
foreach (var provider in aiContextProviders)
{
if (!stateKeys.Add(provider.StateKey))
foreach (var key in provider.StateKeys)
{
throw new InvalidOperationException(
$"Multiple providers use the same state key '{provider.StateKey}'. Each provider must use a unique state key to avoid overwriting each other's state.");
if (!stateKeys.Add(key))
{
throw new InvalidOperationException(
$"Multiple providers use the same state key '{key}'. Each provider must use a unique state key to avoid overwriting each other's state.");
}
}
}
}
@@ -905,11 +920,16 @@ public sealed partial class ChatClientAgent : AIAgent
$"The default {nameof(InMemoryChatHistoryProvider)} uses the state key '{nameof(InMemoryChatHistoryProvider)}', which is already used by one of the configured AIContextProviders. Each provider must use a unique state key to avoid overwriting each other's state. To resolve this, either configure a different state key for the AIContextProvider that is using '{nameof(InMemoryChatHistoryProvider)}' as its state key, or provide a custom ChatHistoryProvider with a unique state key.");
}
if (chatHistoryProvider is not null
&& stateKeys.Contains(chatHistoryProvider.StateKey))
if (chatHistoryProvider is not null)
{
throw new InvalidOperationException(
$"The ChatHistoryProvider '{chatHistoryProvider.GetType().Name}' uses the state key '{chatHistoryProvider.StateKey}' which is already used by one of the configured AIContextProviders. Each provider must use a unique state key to avoid overwriting each other's state. To resolve this, either configure a different state key for the AIContextProvider that is using '{chatHistoryProvider.StateKey}' as its state key, or reconfigure the custom ChatHistoryProvider with a unique state key.");
foreach (var key in chatHistoryProvider.StateKeys)
{
if (stateKeys.Contains(key))
{
throw new InvalidOperationException(
$"The ChatHistoryProvider '{chatHistoryProvider.GetType().Name}' uses state key '{key}' which is already used by one of the configured AIContextProviders. Each provider must use unique state keys to avoid overwriting each other's state. To resolve this, either configure different state keys for the AIContextProvider that shares keys with the ChatHistoryProvider, or reconfigure the custom ChatHistoryProvider with unique state keys.");
}
}
}
return stateKeys;
@@ -54,6 +54,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
private const string ContentEmbeddingField = "ContentEmbedding";
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
#pragma warning disable CA2213 // VectorStore is not owned by this class - caller is responsible for disposal
private readonly VectorStore _vectorStore;
@@ -88,7 +89,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
Func<AgentSession?, State> stateInitializer,
ChatHistoryMemoryProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
: base(options?.SearchInputMessageFilter, options?.StorageInputRequestMessageFilter, options?.StorageInputResponseMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
Throw.IfNull(stateInitializer),
@@ -128,7 +129,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default)
@@ -75,8 +75,16 @@ public sealed class ChatHistoryMemoryProviderOptions
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputRequestMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to response messages when storing recent chat history
/// during <see cref="AIContextProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider does not apply any filtering and includes all response messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputResponseMessageFilter { get; set; }
/// <summary>
/// Behavior choices for the provider.
/// </summary>
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -15,8 +13,7 @@ namespace Microsoft.Agents.AI;
/// and a markdown body with instructions. Resource files referenced in the body are validated at
/// discovery time and read from disk on demand.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAgentSkill
internal sealed class FileAgentSkill
{
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
@@ -25,8 +22,8 @@ public sealed class FileAgentSkill
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
internal FileAgentSkill(
FileAgentSkillFrontmatter frontmatter,
public FileAgentSkill(
SkillFrontmatter frontmatter,
string body,
string sourcePath,
IReadOnlyList<string>? resourceNames = null)
@@ -40,20 +37,20 @@ public sealed class FileAgentSkill
/// <summary>
/// Gets the parsed YAML frontmatter (name and description).
/// </summary>
public FileAgentSkillFrontmatter Frontmatter { get; }
public SkillFrontmatter Frontmatter { get; }
/// <summary>
/// Gets the SKILL.md body content (without the YAML frontmatter).
/// </summary>
public string Body { get; }
/// <summary>
/// Gets the directory path where the skill was discovered.
/// </summary>
public string SourcePath { get; }
/// <summary>
/// Gets the SKILL.md body content (without the YAML frontmatter).
/// </summary>
internal string Body { get; }
/// <summary>
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
/// </summary>
internal IReadOnlyList<string> ResourceNames { get; }
public IReadOnlyList<string> ResourceNames { get; }
}
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
@@ -10,7 +9,6 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -19,11 +17,11 @@ namespace Microsoft.Agents.AI;
/// </summary>
/// <remarks>
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> levels) for SKILL.md files.
/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
/// Resource paths are checked against path traversal and symlink escape attacks.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed partial class FileAgentSkillLoader
internal sealed partial class FileAgentSkillLoader
{
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
@@ -36,17 +34,6 @@ public sealed partial class FileAgentSkillLoader
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches resource file references in skill markdown. Group 1 = relative file path.
// Supports two forms:
// 1. Markdown links: [text](path/file.ext)
// 2. Backtick-quoted paths: `path/file.ext`
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
// Intentionally conservative: only matches paths with word characters, hyphens, dots,
// and forward slashes. Paths with spaces or special characters are not supported.
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", `./scripts/run.py` → "./scripts/run.py",
// [p](../shared/doc.txt) → "../shared/doc.txt"
private static readonly Regex s_resourceLinkRegex = new(@"(?:\[.*?\]\(|`)(\.?\.?/?[\w][\w\-./]*\.\w+)(?:\)|`)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
// Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
@@ -58,14 +45,22 @@ public sealed partial class FileAgentSkillLoader
private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
private readonly ILogger _logger;
private readonly HashSet<string> _allowedResourceExtensions;
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
internal FileAgentSkillLoader(ILogger logger)
/// <param name="allowedResourceExtensions">File extensions to recognize as skill resources. When <see langword="null"/>, defaults are used.</param>
internal FileAgentSkillLoader(ILogger logger, IEnumerable<string>? allowedResourceExtensions = null)
{
this._logger = logger;
ValidateExtensions(allowedResourceExtensions);
this._allowedResourceExtensions = new HashSet<string>(
allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"],
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
@@ -117,7 +112,7 @@ public sealed partial class FileAgentSkillLoader
/// <exception cref="InvalidOperationException">
/// The resource is not registered, resolves outside the skill directory, or does not exist.
/// </exception>
public async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
internal async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
{
resourceName = NormalizeResourcePath(resourceName);
@@ -189,32 +184,27 @@ public sealed partial class FileAgentSkillLoader
}
}
private FileAgentSkill? ParseSkillFile(string skillDirectoryPath)
private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath)
{
string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName);
string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName);
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
if (!this.TryParseSkillDocument(content, skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body))
if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
{
return null;
}
List<string> resourceNames = ExtractResourcePaths(body);
if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name))
{
return null;
}
List<string> resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
return new FileAgentSkill(
frontmatter: frontmatter,
body: body,
sourcePath: skillDirectoryPath,
sourcePath: skillDirectoryFullPath,
resourceNames: resourceNames);
}
private bool TryParseSkillDocument(string content, string skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body)
private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
{
frontmatter = null!;
body = null!;
@@ -270,40 +260,90 @@ public sealed partial class FileAgentSkillLoader
return false;
}
frontmatter = new FileAgentSkillFrontmatter(name, description);
frontmatter = new SkillFrontmatter(name, description);
body = content.Substring(match.Index + match.Length).TrimStart();
return true;
}
private bool ValidateResources(string skillDirectoryPath, List<string> resourceNames, string skillName)
/// <summary>
/// Scans a skill directory for resource files matching the configured extensions.
/// </summary>
/// <remarks>
/// Recursively walks <paramref name="skillDirectoryFullPath"/> and collects files whose extension
/// matches <see cref="_allowedResourceExtensions"/>, excluding <c>SKILL.md</c> itself. Each candidate
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
/// a warning.
/// </remarks>
private List<string> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar;
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
foreach (string resourceName in resourceNames)
var resources = new List<string>();
#if NET
var enumerationOptions = new EnumerationOptions
{
string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName));
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
if (!IsPathWithinDirectory(fullPath, normalizedSkillPath))
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
LogResourcePathTraversal(this._logger, skillName, resourceName);
return false;
continue;
}
if (!File.Exists(fullPath))
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
LogMissingResource(this._logger, skillName, resourceName);
return false;
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
continue;
}
if (HasSymlinkInPath(fullPath, normalizedSkillPath))
// Normalize the enumerated path to guard against non-canonical forms
// (redundant separators, 8.3 short names, etc.) that would produce
// malformed relative resource names.
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
LogResourceSymlinkEscape(this._logger, skillName, resourceName);
return false;
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize to forward slashes
string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length);
resources.Add(NormalizeResourcePath(relativePath));
}
return true;
return resources;
}
/// <summary>
@@ -342,22 +382,6 @@ public sealed partial class FileAgentSkillLoader
return false;
}
private static List<string> ExtractResourcePaths(string content)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var paths = new List<string>();
foreach (Match m in s_resourceLinkRegex.Matches(content))
{
string path = NormalizeResourcePath(m.Groups[1].Value);
if (seen.Add(path))
{
paths.Add(path);
}
}
return paths;
}
/// <summary>
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
@@ -378,6 +402,43 @@ public sealed partial class FileAgentSkillLoader
return path;
}
/// <summary>
/// Replaces control characters in a file path with '?' to prevent log injection
/// via crafted filenames (e.g., filenames containing newlines on Linux).
/// </summary>
private static string SanitizePathForLog(string path)
{
char[]? chars = null;
for (int i = 0; i < path.Length; i++)
{
if (char.IsControl(path[i]))
{
chars ??= path.ToCharArray();
chars[i] = '?';
}
}
return chars is null ? path : new string(chars);
}
private static void ValidateExtensions(IEnumerable<string>? extensions)
{
if (extensions is null)
{
return;
}
foreach (string ext in extensions)
{
if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal))
{
#pragma warning disable CA2208 // Instantiate argument exceptions correctly
throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions));
#pragma warning restore CA2208 // Instantiate argument exceptions correctly
}
}
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);
@@ -396,18 +457,18 @@ public sealed partial class FileAgentSkillLoader
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")]
private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName);
[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, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName);
[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.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
}
@@ -1,35 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides access to loaded skills and the skill loader for use by <see cref="FileAgentSkillScriptExecutor"/> implementations.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAgentSkillScriptExecutionContext
{
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillScriptExecutionContext"/> class.
/// </summary>
/// <param name="skills">The loaded skills dictionary.</param>
/// <param name="loader">The skill loader for reading resources.</param>
internal FileAgentSkillScriptExecutionContext(Dictionary<string, FileAgentSkill> skills, FileAgentSkillLoader loader)
{
this.Skills = skills;
this.Loader = loader;
}
/// <summary>
/// Gets the loaded skills keyed by name.
/// </summary>
public IReadOnlyDictionary<string, FileAgentSkill> Skills { get; }
/// <summary>
/// Gets the skill loader for reading resources.
/// </summary>
public FileAgentSkillLoader Loader { get; }
}
@@ -1,25 +0,0 @@
// 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;
/// <summary>
/// Represents the tools and instructions contributed by a <see cref="FileAgentSkillScriptExecutor"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAgentSkillScriptExecutionDetails
{
/// <summary>
/// Gets the additional instructions to provide to the agent for script execution.
/// </summary>
public string? Instructions { get; set; }
/// <summary>
/// Gets the additional tools to provide to the agent for script execution.
/// </summary>
public IReadOnlyList<AITool>? Tools { get; set; }
}
@@ -1,42 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Defines the contract for skill script execution modes.
/// </summary>
/// <remarks>
/// <para>
/// A <see cref="FileAgentSkillScriptExecutor"/> provides the instructions and tools needed to enable
/// script execution within an agent skill. Concrete implementations determine how scripts
/// are executed (e.g., via the LLM's hosted code interpreter, an external executor, or a hybrid approach).
/// </para>
/// <para>
/// Use the static factory methods to create instances:
/// <list type="bullet">
/// <item><description><see cref="HostedCodeInterpreter"/> — executes scripts using the LLM provider's built-in code interpreter.</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class FileAgentSkillScriptExecutor
{
/// <summary>
/// Creates a <see cref="FileAgentSkillScriptExecutor"/> that uses the LLM provider's hosted code interpreter for script execution.
/// </summary>
/// <returns>A <see cref="FileAgentSkillScriptExecutor"/> instance configured for hosted code interpreter execution.</returns>
public static FileAgentSkillScriptExecutor HostedCodeInterpreter() => new HostedCodeInterpreterFileAgentSkillScriptExecutor();
/// <summary>
/// Returns the tools and instructions contributed by this executor.
/// </summary>
/// <param name="context">
/// The execution context provided by the skills provider, containing the loaded skills
/// and the skill loader for reading resources.
/// </param>
/// <returns>A <see cref="FileAgentSkillScriptExecutionDetails"/> containing the executor's tools and instructions.</returns>
protected internal abstract FileAgentSkillScriptExecutionDetails GetExecutionDetails(FileAgentSkillScriptExecutionContext context);
}
@@ -48,21 +48,21 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
<available_skills>
{skills}
{0}
</available_skills>
When a task aligns with a skill's domain:
- Use `load_skill` to retrieve the skill's instructions
- Follow the provided guidance
- Use `read_skill_resource` to read any references or other files mentioned by the skill, always using the full path as written (e.g. `references/FAQ.md`, not just `FAQ.md`)
{executor_instructions}
1. Use `load_skill` to retrieve the skill's instructions
2. Follow the provided guidance
3. Use `read_skill_resource` to read any references or other files mentioned by the skill
Only load what is needed, when it is needed.
""";
private readonly Dictionary<string, FileAgentSkill> _skills;
private readonly ILogger<FileAgentSkillsProvider> _logger;
private readonly FileAgentSkillLoader _loader;
private readonly IEnumerable<AITool> _tools;
private readonly AITool[] _tools;
private readonly string? _skillsInstructionPrompt;
/// <summary>
@@ -88,16 +88,12 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
this._loader = new FileAgentSkillLoader(this._logger);
this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions);
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
var executionDetails = options?.ScriptExecutor is { } executor
? executor.GetExecutionDetails(new(this._skills, this._loader))
: null;
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills, executionDetails?.Instructions);
AITool[] baseTools =
this._tools =
[
AIFunctionFactory.Create(
this.LoadSkill,
@@ -108,10 +104,6 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
name: "read_skill_resource",
description: "Reads a file associated with a skill, such as references or assets."),
];
this._tools = executionDetails?.Tools is { Count: > 0 } executorTools
? baseTools.Concat(executorTools)
: baseTools;
}
/// <inheritdoc />
@@ -125,7 +117,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
return new ValueTask<AIContext>(new AIContext
{
Instructions = this._skillsInstructionPrompt,
Tools = this._tools,
Tools = this._tools
});
}
@@ -174,9 +166,25 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
}
}
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills, string? instructions)
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills)
{
string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
string promptTemplate = DefaultSkillsInstructionPrompt;
if (options?.SkillsInstructionPrompt is { } optionsInstructions)
{
try
{
_ = string.Format(optionsInstructions, string.Empty);
promptTemplate = optionsInstructions;
}
catch (FormatException ex)
{
throw new ArgumentException(
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
nameof(options),
ex);
}
}
if (skills.Count == 0)
{
@@ -195,9 +203,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
sb.AppendLine(" </skill>");
}
return promptTemplate
.Replace("{skills}", sb.ToString().TrimEnd())
.Replace("{executor_instructions}", instructions ?? "\n");
return string.Format(promptTemplate, sb.ToString().TrimEnd());
}
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]

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