mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-xunit3-mtp-upgrade
This commit is contained in:
@@ -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 }}
|
||||
@@ -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/
|
||||
|
||||
@@ -11,7 +11,7 @@ model:
|
||||
topP: 0.95
|
||||
connection:
|
||||
kind: key
|
||||
apiKey: =Env.OPENAI_APIKEY
|
||||
apiKey: =Env.OPENAI_API_KEY
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
# These are optional elements. Feel free to remove any of them.
|
||||
status: accepted
|
||||
contact: westey-m
|
||||
date: 2026-02-25
|
||||
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
|
||||
consulted:
|
||||
informed:
|
||||
---
|
||||
|
||||
# AgentSession serialization
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Serializing AgentSessions is done today by calling SerializeSession on the AIAgent instance and deserialization
|
||||
is done via the DeserializeSession method on the AIAgent instance.
|
||||
|
||||
This approach has some drawbacks:
|
||||
|
||||
1. It requires each AgentSession implementation to implement its own serialization logic. This can lead to inconsistencies and errors if not done correctly.
|
||||
1. It means that only one serialization format can be supported at a time. If we want to support multiple formats (e.g., JSON, XML, binary), we would need to implement separate serialization logic for each format.
|
||||
1. It is not possible to serialize and deserialize lists of AgentSessions, since each need to be handled individually.
|
||||
1. Users may not realise that they need to call these specific methods to serialize/deserialize AgentSessions.
|
||||
|
||||
The reason why this approach was chosen initially is that AgentSessions may have behaviors that are attached to them and only the agent knows what behaviors to attach.
|
||||
These behaviors also have their own state that are attached to the AgentSession.
|
||||
The behaviors may have references to SDKs or other resources that cannot be created via standard deserialization mechanisms.
|
||||
E.g. an AgentSession may have a custom ChatMessageStore that knows how to store chat history in a specific storage backend and has a reference to the SDK client for that backend.
|
||||
When deserializing the AgentSession, we need to make sure that the ChatMessageStore is created with the correct SDK client.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- A. Ability to continue to support custom behaviors (AIContextProviders / ChatHistoryProviders).
|
||||
- B. Ability to serialize and deserialize AgentSessions via standard serialization mechanisms, e.g. JsonSerializer.Serialize and JsonSerializer.Deserialize.
|
||||
- C. Ability for the caller to access custom providers.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Option 1: Separate state from behavior, serialize state only and re-attach behavior on first usage
|
||||
- Option 2: Separate state from behavior, and only have state on AgentSession
|
||||
- Option 3: Keep the current approach of custom Serialize/Deserialize methods
|
||||
|
||||
### Option 1: Separate state from behavior, serialize state only and re-attach behavior on first usage
|
||||
|
||||
Decision Drivers satisfied: A, B and C (C only partially)
|
||||
|
||||
Have separate properties on the AgentSession for state and behavior and mark the behavior property with [JsonIgnore].
|
||||
After deserializing the AgentSession, the behavior is null and when the AgentSession is first used by the Agent, the behavior is created and attached to the AgentSession.
|
||||
|
||||
This requires polymorphic deserialization to be supported, so that the correct AgentSession subclass and the correct behavior state is created during deserialization.
|
||||
Since the implementations for AgentSessions and their behaviors are not all known at compile time, we need a way to register custom AgentSession types and their corresponding behavior types for serialization with System.Text.Json on our JsonUtilities helpers.
|
||||
|
||||
A drawback of this approach is that the AgentSession is in an incomplete state after deserialization until it is first used,
|
||||
so if a user was to call `GetService<MyBehavior>()` on the AgentSession before it is used by the Agent, it would return null.
|
||||
|
||||
Behaviors like ChatMessageStore and AIContextProviders would need to change to support taking state as input and exposing state publicly.
|
||||
|
||||
```csharp
|
||||
public class ChatClientAgentSession
|
||||
{
|
||||
...
|
||||
public ChatMessageStoreState ChatMessageStoreState { get; }
|
||||
public ChatMessageStore? ChatMessageStore { get; }
|
||||
...
|
||||
}
|
||||
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
|
||||
[JsonDerivedType(typeof(InMemoryChatMessageStoreState), nameof(InMemoryChatMessageStoreState))]
|
||||
public abstract class ChatMessageStoreState
|
||||
{
|
||||
}
|
||||
public class InMemoryChatMessageStoreState : ChatMessageStoreState
|
||||
{
|
||||
public IList<ChatMessage> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
public abstract class ChatMessageStore<TState>
|
||||
where TState : ChatMessageStoreState
|
||||
{
|
||||
...
|
||||
public abstract TState State { get; }
|
||||
...
|
||||
}
|
||||
|
||||
public sealed class InMemoryChatMessageStore : ChatMessageStore<InMemoryChatMessageStoreState>, IList<ChatMessage>
|
||||
{
|
||||
private readonly InMemoryChatMessageStoreState _state;
|
||||
|
||||
public InMemoryChatMessageStore(InMemoryChatMessageStoreState? state)
|
||||
{
|
||||
this._state = state ?? new InMemoryChatMessageStoreState();
|
||||
}
|
||||
|
||||
public override InMemoryChatMessageStoreState State => this._state;
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
ChatClientAgent factories would need to change to support creating behaviors based on state:
|
||||
|
||||
```csharp
|
||||
public Func<ChatMessageStoreFactoryContext, ChatMessageStore>? ChatMessageStoreFactory { get; set; }
|
||||
|
||||
public class ChatMessageStoreFactoryContext
|
||||
{
|
||||
public ChatMessageStoreState? State { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
The run behavior of the ChatClientAgent would be as follows:
|
||||
|
||||
1. If an AgentSession is provided, check if the ChatMessageStore property is null.
|
||||
1. If it is, check if the ChatMessageStoreState property is null.
|
||||
1. If ChatMessageStoreState is null, check if there is a provided ChatMessageStoreFactory.
|
||||
1. If there is, call it with a ChatMessageStoreFactoryContext containing null State to create a default ChatMessageStore behavior, and update the AgentSession with the created behavior and its state.
|
||||
2. If there is not, create a default InMemoryChatMessageStore behavior, and update the AgentSession with the created behavior and its state.
|
||||
1. If ChatMessageStoreState is not null, check if there is a provided ChatMessageStoreFactory.
|
||||
1. If there is, call it with a ChatMessageStoreFactoryContext containing the State to create a ChatMessageStore behavior based on the state.
|
||||
2. If there is not, create an InMemoryChatMessageStore behavior based on the State.
|
||||
|
||||
### Option 2: Separate state from behavior, and only have state on AgentSession
|
||||
|
||||
Decision Drivers satisfied: A, B and C.
|
||||
|
||||
This is similar to Option 1 but instead of having a behavior property on the AgentSession, we only have a StateBag property on the AgentSession.
|
||||
Behaviors really make more sense to live with the agent rather than the Session, but state should live on the session.
|
||||
When the AgentSession is used by the Agent, the Agent runs the behaviors against the Session, and the behavior stores it's state on the Session StateBag.
|
||||
|
||||
This means that users are unable to access the behavior from the AgentSession, e.g. via `AgentSession.GetService<TBehavior>()`.
|
||||
|
||||
However, the behaviors can be public properties on the Agent or can be retrieved from the agent via `AIAgent.GetService<MyAIContextProvider>()`.
|
||||
|
||||
```csharp
|
||||
public class AgentSession
|
||||
{
|
||||
...
|
||||
public AgentSessionStateBag StateBag { get; protected set; } = new();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Option 3: Keep the current approach of custom Serialize/Deserialize methods
|
||||
|
||||
Decision Drivers satisfied: A and C
|
||||
|
||||
This option keeps the current approach of having custom Serialize/Deserialize methods on the AgentSession and AIAgent.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option:
|
||||
|
||||
**Option 2** — separate state from behavior, with only state on the AgentSession — because it satisfies all decision drivers and provides the cleanest separation of concerns. Since not all AgentSession implementations have yet been cleanly separated from their behaviors, AIAgent.SerializeSession and AIAgent.DeserializeSession is kept for the time being, but most session types can be serialized and deserialized directly using JsonSerializer.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good, because providers are fully stateless — the same provider instance works correctly across any number of concurrent sessions without risk of state leakage.
|
||||
- Good, because `AgentSession` can be serialized and deserialized with standard `System.Text.Json` mechanisms, satisfying decision driver B.
|
||||
- Good, because the generic `StateBag` is extensible — new providers can store arbitrary state without requiring changes to the session class.
|
||||
- Good, because users can access providers via the agent (e.g. `agent.GetService<InMemoryChatHistoryProvider>()`) satisfying decision driver C.
|
||||
- Good, because sessions are always in a complete and valid state after deserialization — there is no "incomplete until first use" problem as in Option 1.
|
||||
- Neutral, because providers cannot be accessed directly from the session; callers must go through the agent. This is a minor usability trade-off but keeps the session focused on state only.
|
||||
- Bad, because each provider must be disciplined about using `ProviderSessionState<T>` and not storing session-specific data in instance fields. This is a correctness concern for custom provider implementers.
|
||||
@@ -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.* -->
|
||||
|
||||
@@ -287,6 +287,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" />
|
||||
@@ -327,6 +333,10 @@
|
||||
<File Path="../docs/decisions/0012-python-typeddict-options.md" />
|
||||
<File Path="../docs/decisions/0013-python-get-response-simplification.md" />
|
||||
<File Path="../docs/decisions/0014-feature-collections.md" />
|
||||
<File Path="../docs/decisions/0015-agent-run-context.md" />
|
||||
<File Path="../docs/decisions/0016-python-context-middleware.md" />
|
||||
<File Path="../docs/decisions/0017-agent-additional-properties.md" />
|
||||
<File Path="../docs/decisions/0018-agentthread-serialization.md" />
|
||||
<File Path="../docs/decisions/adr-short-template.md" />
|
||||
<File Path="../docs/decisions/adr-template.md" />
|
||||
<File Path="../docs/decisions/README.md" />
|
||||
|
||||
@@ -92,7 +92,6 @@ namespace SampleApp
|
||||
private readonly IChatClient _chatClient;
|
||||
|
||||
public UserInfoMemory(IChatClient chatClient, Func<AgentSession?, UserInfo>? stateInitializer = null)
|
||||
: base(null, null)
|
||||
{
|
||||
this._sessionState = new ProviderSessionState<UserInfo>(
|
||||
stateInitializer ?? (_ => new UserInfo()),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ namespace SampleApp
|
||||
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"))),
|
||||
|
||||
@@ -10,7 +10,6 @@ using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
|
||||
|
||||
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";
|
||||
@@ -39,9 +38,10 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session
|
||||
// We can use the ChatHistoryProvider, that is also used by the agent, to read the
|
||||
// chat history from the session state, and see how the reducer is affecting the stored messages.
|
||||
// Here we expect to see 2 messages, the original user message and the agent response message.
|
||||
var provider = agent.GetService<InMemoryChatHistoryProvider>();
|
||||
List<ChatMessage>? chatHistory = provider?.GetMessages(session);
|
||||
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
|
||||
if (session.TryGetInMemoryChatHistory(out var chatHistory))
|
||||
{
|
||||
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
|
||||
}
|
||||
|
||||
// Invoke the agent a few more times.
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke about a robot.", session));
|
||||
@@ -51,16 +51,22 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a robot.", session)
|
||||
// to trigger the reducer is just before messages are contributed to a new agent run.
|
||||
// So at this time, we have not yet triggered the reducer for the most recently added messages,
|
||||
// and they are still in the chat history.
|
||||
chatHistory = provider?.GetMessages(session);
|
||||
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
|
||||
if (session.TryGetInMemoryChatHistory(out chatHistory))
|
||||
{
|
||||
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
|
||||
}
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke about a lemur.", session));
|
||||
chatHistory = provider?.GetMessages(session);
|
||||
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
|
||||
if (session.TryGetInMemoryChatHistory(out chatHistory))
|
||||
{
|
||||
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
|
||||
}
|
||||
|
||||
// At this point, the chat history has exceeded the limit and the original message will not exist anymore,
|
||||
// so asking a follow up question about it may not work as expected.
|
||||
Console.WriteLine(await agent.RunAsync("What was the first joke I asked you to tell again?", session));
|
||||
|
||||
chatHistory = provider?.GetMessages(session);
|
||||
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
|
||||
if (session.TryGetInMemoryChatHistory(out chatHistory))
|
||||
{
|
||||
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
+35
@@ -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>
|
||||
}
|
||||
+79
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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>
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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>
|
||||
+3
@@ -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();
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"RazorWebClient": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:58080;http://localhost:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Service": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:55001;http://localhost:5001"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<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.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
@@ -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."
|
||||
+70
@@ -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
|
||||
+70
@@ -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,23 @@ 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;
|
||||
|
||||
/// <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,7 +60,12 @@ 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 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 key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
|
||||
@@ -245,8 +255,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 +273,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="AgentSession"/>.
|
||||
/// </summary>
|
||||
public static class AgentSessionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to retrieve the in-memory chat history messages associated with the specified agent session, if the agent is storing memories in the session using the <see cref="InMemoryChatHistoryProvider"/>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
|
||||
/// </remarks>
|
||||
/// <param name="session">The agent session from which to retrieve in-memory chat history.</param>
|
||||
/// <param name="messages">When this method returns, contains the list of chat history messages if available; otherwise, null.</param>
|
||||
/// <param name="stateKey">An optional key used to identify the chat history state in the session's state bag. If null, the default key for
|
||||
/// in-memory chat history is used.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options to use when accessing the session state. If null, default options are used.</param>
|
||||
/// <returns><see langword="true"/> if the in-memory chat history messages were found and retrieved; <see langword="false"/> otherwise.</returns>
|
||||
public static bool TryGetInMemoryChatHistory(this AgentSession session, [MaybeNullWhen(false)] out List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
_ = Throw.IfNull(session);
|
||||
|
||||
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state?.Messages is not null)
|
||||
{
|
||||
messages = state.Messages;
|
||||
return true;
|
||||
}
|
||||
|
||||
messages = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the in-memory chat message history for the specified agent session, replacing any existing messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
|
||||
/// If messages are set, but a different <see cref="ChatHistoryProvider"/> is used, or if chat history is stored in the underlying AI service, the messages will be ignored.
|
||||
/// </remarks>
|
||||
/// <param name="session">The agent session whose in-memory chat history will be updated.</param>
|
||||
/// <param name="messages">The list of chat messages to store in memory for the session. Replaces any existing messages for the specified
|
||||
/// state key.</param>
|
||||
/// <param name="stateKey">The key used to identify the in-memory chat history within the session's state bag. If null, a default key is
|
||||
/// used.</param>
|
||||
/// <param name="jsonSerializerOptions">The serializer options used when accessing or storing the state. If null, default options are applied.</param>
|
||||
public static void SetInMemoryChatHistory(this AgentSession session, List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
_ = Throw.IfNull(session);
|
||||
|
||||
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state is not null)
|
||||
{
|
||||
state.Messages = messages;
|
||||
return;
|
||||
}
|
||||
|
||||
session.StateBag.SetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), new InMemoryChatHistoryProvider.State() { Messages = messages }, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
}
|
||||
}
|
||||
@@ -42,21 +42,27 @@ 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 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>
|
||||
@@ -216,7 +222,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 +235,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,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()),
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -522,21 +522,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());
|
||||
|
||||
@@ -87,7 +87,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 +99,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),
|
||||
@@ -123,7 +125,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 +136,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 +152,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 +164,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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
+113
@@ -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);
|
||||
}
|
||||
|
||||
+18
-1
@@ -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);
|
||||
|
||||
|
||||
@@ -52,7 +52,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)),
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -22,7 +22,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(),
|
||||
|
||||
@@ -109,7 +109,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// Use the ChatHistoryProvider from options if provided.
|
||||
// If one was not provided, and we later find out that the underlying service does not manage chat history server-side,
|
||||
// we will use the default InMemoryChatHistoryProvider at that time.
|
||||
this.ChatHistoryProvider = options?.ChatHistoryProvider;
|
||||
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.
|
||||
@@ -743,25 +743,31 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(responseConversationId))
|
||||
{
|
||||
if (this.ChatHistoryProvider is not null)
|
||||
if (this._agentOptions?.ChatHistoryProvider is not null)
|
||||
{
|
||||
// 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.
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured.");
|
||||
if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true)
|
||||
{
|
||||
this._logger.LogAgentChatClientHistoryProviderConflict(nameof(ChatClientAgentSession.ConversationId), nameof(this.ChatHistoryProvider), this.Id, this.GetLoggingAgentName());
|
||||
}
|
||||
|
||||
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured.");
|
||||
}
|
||||
|
||||
if (this._agentOptions?.ClearOnChatHistoryProviderConflict is true)
|
||||
{
|
||||
this.ChatHistoryProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If we got a conversation id back from the chat client, it means that the service supports server side session storage
|
||||
// so we should update the session with the new id.
|
||||
session.ConversationId = responseConversationId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// If the service doesn't use service side chat history storage (i.e. we got no id back from invocation), and
|
||||
// the agent has no ChatHistoryProvider yet, we should use the default InMemoryChatHistoryProvider so that
|
||||
// we have somewhere to store the chat history.
|
||||
this.ChatHistoryProvider ??= new InMemoryChatHistoryProvider();
|
||||
}
|
||||
}
|
||||
|
||||
private Task NotifyChatHistoryProviderOfFailureAsync(
|
||||
@@ -807,13 +813,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
|
||||
{
|
||||
ChatHistoryProvider? provider = this.ChatHistoryProvider;
|
||||
|
||||
if (session.ConversationId is not null && provider is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"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 the agent has a {nameof(this.ChatHistoryProvider)} configured.");
|
||||
}
|
||||
ChatHistoryProvider? provider = session.ConversationId is null ? this.ChatHistoryProvider : null;
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
|
||||
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
|
||||
|
||||
@@ -56,4 +56,17 @@ internal static partial class ChatClientAgentLogMessages
|
||||
string agentId,
|
||||
string agentName,
|
||||
Type clientType);
|
||||
|
||||
/// <summary>
|
||||
/// Logs <see cref="ChatClientAgent"/> warning about <see cref="ChatHistoryProvider"/> conflict.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Agent {AgentId}/{AgentName}: Only {ConversationIdName} or {ChatHistoryProviderName} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {ChatHistoryProviderName} configured.")]
|
||||
public static partial void LogAgentChatClientHistoryProviderConflict(
|
||||
this ILogger logger,
|
||||
string conversationIdName,
|
||||
string chatHistoryProviderName,
|
||||
string agentId,
|
||||
string agentName);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,36 @@ public sealed class ChatClientAgentOptions
|
||||
/// </remarks>
|
||||
public bool UseProvidedChatClientAsIs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to set the <see cref="ChatClientAgent.ChatHistoryProvider"/> to <see langword="null"/>
|
||||
/// if the underlying AI service indicates that it manages chat history (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that even if this setting is set to <see langword="false"/>, the <see cref="ChatHistoryProvider"/> will still not be used if the underlying AI service indicates that it manages chat history.
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="true"/>.
|
||||
/// </value>
|
||||
public bool ClearOnChatHistoryProviderConflict { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to log a warning if the underlying AI service indicates that it manages chat history
|
||||
/// (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Default is <see langword="true"/>.
|
||||
/// </value>
|
||||
public bool WarnOnChatHistoryProviderConflict { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception is thrown if the underlying AI service indicates that it manages chat history
|
||||
/// (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Default is <see langword="true"/>.
|
||||
/// </value>
|
||||
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
|
||||
/// </summary>
|
||||
@@ -71,5 +101,9 @@ public sealed class ChatClientAgentOptions
|
||||
ChatOptions = this.ChatOptions?.Clone(),
|
||||
ChatHistoryProvider = this.ChatHistoryProvider,
|
||||
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
|
||||
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
|
||||
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
|
||||
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
||||
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,7 +88,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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -174,7 +174,8 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
promptTemplate = string.Format(optionsInstructions, string.Empty);
|
||||
_ = string.Format(optionsInstructions, string.Empty);
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class TextSearchProvider : MessageAIContextProvider
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync,
|
||||
TextSearchProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
|
||||
: base(options?.SearchInputMessageFilter, options?.StorageInputRequestMessageFilter, options?.StorageInputResponseMessageFilter)
|
||||
{
|
||||
this._sessionState = new ProviderSessionState<TextSearchProviderState>(
|
||||
_ => new TextSearchProviderState(),
|
||||
|
||||
@@ -86,7 +86,16 @@ public sealed class TextSearchProviderOptions
|
||||
/// 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 updating the recent message
|
||||
/// memory during <see cref="AIContextProvider.InvokedAsync"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// When <see langword="null"/>, the provider defaults to including all messages.
|
||||
/// </value>
|
||||
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputResponseMessageFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of <see cref="ChatRole"/> types to filter recent messages to
|
||||
|
||||
@@ -543,7 +543,9 @@ public class AIContextProviderTests
|
||||
var storedRequest = provider.LastStoredContext!.RequestMessages.ToList();
|
||||
Assert.Single(storedRequest);
|
||||
Assert.Equal("External", storedRequest[0].Text);
|
||||
Assert.Same(responseMessages, provider.LastStoredContext.ResponseMessages);
|
||||
var storedResponse = provider.LastStoredContext.ResponseMessages!.ToList();
|
||||
Assert.Single(storedResponse);
|
||||
Assert.Equal("Response", storedResponse[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -565,13 +567,14 @@ public class AIContextProviderTests
|
||||
{
|
||||
// Arrange - filter that only keeps System messages
|
||||
var provider = new TestAIContextProvider(
|
||||
storeInputMessageFilter: msgs => msgs.Where(m => m.Role == ChatRole.System));
|
||||
storeInputRequestMessageFilter: msgs => msgs.Where(m => m.Role == ChatRole.System),
|
||||
storeInputResponseMessageFilter: msgs => msgs.Where(m => m.Role == ChatRole.Assistant));
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "User msg"),
|
||||
new ChatMessage(ChatRole.System, "System msg")
|
||||
};
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, messages, [new ChatMessage(ChatRole.Assistant, "Response")]);
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, messages, [new ChatMessage(ChatRole.Assistant, "Response"), new ChatMessage(ChatRole.Tool, "Response")]);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context);
|
||||
@@ -581,6 +584,9 @@ public class AIContextProviderTests
|
||||
var storedRequest = provider.LastStoredContext!.RequestMessages.ToList();
|
||||
Assert.Single(storedRequest);
|
||||
Assert.Equal("System msg", storedRequest[0].Text);
|
||||
var storedResponse = provider.LastStoredContext.ResponseMessages!.ToList();
|
||||
Assert.Single(storedResponse);
|
||||
Assert.Equal("Response", storedResponse[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -605,6 +611,87 @@ public class AIContextProviderTests
|
||||
Assert.Equal("External", storedRequest[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedCoreAsync_DefaultResponseFilterPassesAllResponseMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestAIContextProvider();
|
||||
var requestMessages = new[] { new ChatMessage(ChatRole.User, "Request") };
|
||||
var externalResponse = new ChatMessage(ChatRole.Assistant, "ExternalResp");
|
||||
var historyResponse = new ChatMessage(ChatRole.Assistant, "HistoryResp")
|
||||
.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src");
|
||||
var contextResponse = new ChatMessage(ChatRole.Assistant, "ContextResp")
|
||||
.WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, "src");
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, [externalResponse, historyResponse, contextResponse]);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Assert - default response filter is a noop, so all response messages are kept
|
||||
Assert.NotNull(provider.LastStoredContext);
|
||||
var storedResponse = provider.LastStoredContext!.ResponseMessages!.ToList();
|
||||
Assert.Equal(3, storedResponse.Count);
|
||||
Assert.Equal("ExternalResp", storedResponse[0].Text);
|
||||
Assert.Equal("HistoryResp", storedResponse[1].Text);
|
||||
Assert.Equal("ContextResp", storedResponse[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedCoreAsync_UsesCustomResponseFilterAsync()
|
||||
{
|
||||
// Arrange - response filter that only keeps Assistant messages with specific text
|
||||
var provider = new TestAIContextProvider(
|
||||
storeInputResponseMessageFilter: msgs => msgs.Where(m => m.Text == "Keep"));
|
||||
var requestMessages = new[] { new ChatMessage(ChatRole.User, "Request") };
|
||||
var responseMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.Assistant, "Keep"),
|
||||
new ChatMessage(ChatRole.Assistant, "Drop")
|
||||
};
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, responseMessages);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider.LastStoredContext);
|
||||
var storedResponse = provider.LastStoredContext!.ResponseMessages!.ToList();
|
||||
Assert.Single(storedResponse);
|
||||
Assert.Equal("Keep", storedResponse[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedCoreAsync_RequestAndResponseFiltersOperateIndependentlyAsync()
|
||||
{
|
||||
// Arrange - different filters for request and response
|
||||
var provider = new TestAIContextProvider(
|
||||
storeInputRequestMessageFilter: msgs => msgs.Where(m => m.Role == ChatRole.System),
|
||||
storeInputResponseMessageFilter: msgs => msgs.Where(m => m.Text == "Resp1"));
|
||||
var requestMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "User"),
|
||||
new ChatMessage(ChatRole.System, "System")
|
||||
};
|
||||
var responseMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.Assistant, "Resp1"),
|
||||
new ChatMessage(ChatRole.Assistant, "Resp2")
|
||||
};
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, responseMessages);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Assert - request filter kept only System, response filter kept only Resp1
|
||||
Assert.NotNull(provider.LastStoredContext);
|
||||
var storedRequest = provider.LastStoredContext!.RequestMessages.ToList();
|
||||
Assert.Single(storedRequest);
|
||||
Assert.Equal("System", storedRequest[0].Text);
|
||||
var storedResponse = provider.LastStoredContext!.ResponseMessages!.ToList();
|
||||
Assert.Single(storedResponse);
|
||||
Assert.Equal("Resp1", storedResponse[0].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestAIContextProvider : AIContextProvider
|
||||
@@ -620,8 +707,9 @@ public class AIContextProviderTests
|
||||
AIContext? provideContext = null,
|
||||
bool captureFilteredContext = false,
|
||||
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)
|
||||
{
|
||||
this._provideContext = provideContext;
|
||||
this._captureFilteredContext = captureFilteredContext;
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AgentSessionExtensions"/>.
|
||||
/// </summary>
|
||||
public class AgentSessionExtensionsTests
|
||||
{
|
||||
#region TryGetInMemoryChatHistory Tests
|
||||
|
||||
[Fact]
|
||||
public void TryGetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AgentSession session = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => session.TryGetInMemoryChatHistory(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetInMemoryChatHistory_WhenStateExists_ReturnsTrueAndMessages()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
var expectedMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
|
||||
session.StateBag.SetValue(
|
||||
nameof(InMemoryChatHistoryProvider),
|
||||
new InMemoryChatHistoryProvider.State { Messages = expectedMessages });
|
||||
|
||||
// Act
|
||||
var result = session.TryGetInMemoryChatHistory(out var messages);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.NotNull(messages);
|
||||
Assert.Same(expectedMessages, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetInMemoryChatHistory_WhenStateDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
|
||||
// Act
|
||||
var result = session.TryGetInMemoryChatHistory(out var messages);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Null(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
const string CustomKey = "custom-history-key";
|
||||
var expectedMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
session.StateBag.SetValue(
|
||||
CustomKey,
|
||||
new InMemoryChatHistoryProvider.State { Messages = expectedMessages });
|
||||
|
||||
// Act
|
||||
var result = session.TryGetInMemoryChatHistory(out var messages, stateKey: CustomKey);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.NotNull(messages);
|
||||
Assert.Same(expectedMessages, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetInMemoryChatHistory_WithCustomStateKey_DoesNotFindDefaultKey()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
var expectedMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
session.StateBag.SetValue(
|
||||
nameof(InMemoryChatHistoryProvider),
|
||||
new InMemoryChatHistoryProvider.State { Messages = expectedMessages });
|
||||
|
||||
// Act
|
||||
var result = session.TryGetInMemoryChatHistory(out var messages, stateKey: "other-key");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Null(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetInMemoryChatHistory_WhenStateExistsWithNullMessages_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
session.StateBag.SetValue(
|
||||
nameof(InMemoryChatHistoryProvider),
|
||||
new InMemoryChatHistoryProvider.State { Messages = null! });
|
||||
|
||||
// Act
|
||||
var result = session.TryGetInMemoryChatHistory(out var messages);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Null(messages);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SetInMemoryChatHistory Tests
|
||||
|
||||
[Fact]
|
||||
public void SetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AgentSession session = null!;
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => session.SetInMemoryChatHistory(messages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInMemoryChatHistory_WhenNoExistingState_CreatesNewState()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi!")
|
||||
};
|
||||
|
||||
// Act
|
||||
session.SetInMemoryChatHistory(messages);
|
||||
|
||||
// Assert
|
||||
var result = session.TryGetInMemoryChatHistory(out var retrievedMessages);
|
||||
Assert.True(result);
|
||||
Assert.Same(messages, retrievedMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInMemoryChatHistory_WhenExistingState_ReplacesMessages()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
var originalMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Original")
|
||||
};
|
||||
var newMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "New message"),
|
||||
new(ChatRole.Assistant, "New response")
|
||||
};
|
||||
|
||||
session.SetInMemoryChatHistory(originalMessages);
|
||||
|
||||
// Act
|
||||
session.SetInMemoryChatHistory(newMessages);
|
||||
|
||||
// Assert
|
||||
var result = session.TryGetInMemoryChatHistory(out var retrievedMessages);
|
||||
Assert.True(result);
|
||||
Assert.Same(newMessages, retrievedMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
const string CustomKey = "custom-history-key";
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test")
|
||||
};
|
||||
|
||||
// Act
|
||||
session.SetInMemoryChatHistory(messages, stateKey: CustomKey);
|
||||
|
||||
// Assert
|
||||
var result = session.TryGetInMemoryChatHistory(out var retrievedMessages, stateKey: CustomKey);
|
||||
Assert.True(result);
|
||||
Assert.Same(messages, retrievedMessages);
|
||||
|
||||
// Verify default key is not set
|
||||
var defaultResult = session.TryGetInMemoryChatHistory(out _);
|
||||
Assert.False(defaultResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInMemoryChatHistory_WithEmptyList_SetsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var session = new Mock<AgentSession>().Object;
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Act
|
||||
session.SetInMemoryChatHistory(messages);
|
||||
|
||||
// Assert
|
||||
var result = session.TryGetInMemoryChatHistory(out var retrievedMessages);
|
||||
Assert.True(result);
|
||||
Assert.NotNull(retrievedMessages);
|
||||
Assert.Empty(retrievedMessages);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+12
-5
@@ -439,7 +439,9 @@ public class ChatHistoryProviderTests
|
||||
var storedRequest = provider.LastStoredContext!.RequestMessages.ToList();
|
||||
Assert.Single(storedRequest);
|
||||
Assert.Equal("External", storedRequest[0].Text);
|
||||
Assert.Same(responseMessages, provider.LastStoredContext.ResponseMessages);
|
||||
var storedResponse = provider.LastStoredContext.ResponseMessages!.ToList();
|
||||
Assert.Single(storedResponse);
|
||||
Assert.Equal("Response", storedResponse[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -461,13 +463,14 @@ public class ChatHistoryProviderTests
|
||||
{
|
||||
// Arrange - filter that only keeps System messages
|
||||
var provider = new TestChatHistoryProvider(
|
||||
storeInputMessageFilter: msgs => msgs.Where(m => m.Role == ChatRole.System));
|
||||
storeInputRequestMessageFilter: msgs => msgs.Where(m => m.Role == ChatRole.System),
|
||||
storeInputResponseMessageFilter: msgs => msgs.Where(m => m.Role == ChatRole.Assistant));
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "User msg"),
|
||||
new ChatMessage(ChatRole.System, "System msg")
|
||||
};
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, messages, [new ChatMessage(ChatRole.Assistant, "Response")]);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, messages, [new ChatMessage(ChatRole.Assistant, "Response"), new ChatMessage(ChatRole.Tool, "Response")]);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context);
|
||||
@@ -477,6 +480,9 @@ public class ChatHistoryProviderTests
|
||||
var storedRequest = provider.LastStoredContext!.RequestMessages.ToList();
|
||||
Assert.Single(storedRequest);
|
||||
Assert.Equal("System msg", storedRequest[0].Text);
|
||||
var storedResponse = provider.LastStoredContext.ResponseMessages!.ToList();
|
||||
Assert.Single(storedResponse);
|
||||
Assert.Equal("Response", storedResponse[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -529,8 +535,9 @@ public class ChatHistoryProviderTests
|
||||
public TestChatHistoryProvider(
|
||||
IEnumerable<ChatMessage>? provideMessages = 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._provideMessages = provideMessages;
|
||||
}
|
||||
|
||||
+1
-1
@@ -418,7 +418,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
var session = CreateMockSession();
|
||||
var provider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
StorageInputMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External)
|
||||
StorageInputRequestMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External)
|
||||
});
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
|
||||
+64
@@ -2375,6 +2375,70 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync with UseProvidedChatClientAsIs=true skips tool validation
|
||||
/// and does not throw even when server-side function tools exist without matching invocable tools.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_SkipsToolValidationAsync()
|
||||
{
|
||||
// Arrange
|
||||
PromptAgentDefinition definition = new("test-model") { Instructions = "Test" };
|
||||
definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false));
|
||||
|
||||
AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition);
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
ChatOptions = new ChatOptions { Instructions = "Test" },
|
||||
UseProvidedChatClientAsIs = true
|
||||
};
|
||||
|
||||
// Act - should not throw even without tools when UseProvidedChatClientAsIs is true
|
||||
ChatClientAgent agent = await client.GetAIAgentAsync(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync with UseProvidedChatClientAsIs=true still matches provided AIFunction tools
|
||||
/// to server-side function definitions, instead of falling back to the ResponseToolAITool wrapper.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_PreservesProvidedToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
PromptAgentDefinition definition = new("test-model") { Instructions = "Test" };
|
||||
definition.Tools.Add(ResponseTool.CreateFunctionTool("my_function", BinaryData.FromString("{}"), strictModeEnabled: false));
|
||||
|
||||
AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition);
|
||||
|
||||
var providedTool = AIFunctionFactory.Create(() => "test", "my_function", "A test function");
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
UseProvidedChatClientAsIs = true,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "Test",
|
||||
Tools = [providedTool]
|
||||
},
|
||||
};
|
||||
|
||||
// Act - UseProvidedChatClientAsIs is true, but provided AIFunctions should still be matched and preserved
|
||||
ChatClientAgent agent = await client.GetAIAgentAsync(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify the provided AIFunction was matched and preserved in ChatOptions.Tools (not replaced by AsAITool wrapper)
|
||||
var chatOptions = agent.GetService<ChatOptions>();
|
||||
Assert.NotNull(chatOptions);
|
||||
Assert.NotNull(chatOptions!.Tools);
|
||||
Assert.Contains(chatOptions.Tools, t => t is AIFunction af && af.Name == "my_function");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty Version and ID Handling Tests
|
||||
|
||||
+1
-1
@@ -1006,7 +1006,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
s_testDatabaseId,
|
||||
TestContainerId,
|
||||
_ => new CosmosChatHistoryProvider.State(conversationId),
|
||||
storeInputMessageFilter: messages => messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External));
|
||||
storeInputRequestMessageFilter: messages => messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External));
|
||||
|
||||
var requestMessages = new[]
|
||||
{
|
||||
|
||||
+92
@@ -1201,6 +1201,75 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
|
||||
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that conversation history is passed to the agent on subsequent requests.
|
||||
/// This test reproduces the bug described in GitHub issue #3484.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateResponse_WithConversation_SecondRequestIncludesPriorMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "memory-agent";
|
||||
const string Instructions = "You are a helpful assistant.";
|
||||
const string AgentResponse = "Nice to meet you Alice";
|
||||
|
||||
var mockChatClient = new TestHelpers.ConversationMemoryMockChatClient(AgentResponse);
|
||||
this._httpClient = await this.CreateTestServerWithCustomClientAndConversationsAsync(
|
||||
AgentName, Instructions, mockChatClient);
|
||||
|
||||
// Create a conversation
|
||||
string createConvJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new { metadata = new { agent_id = AgentName } });
|
||||
using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json");
|
||||
HttpResponseMessage createConvResponse = await this._httpClient.PostAsync(
|
||||
new Uri("/v1/conversations", UriKind.Relative), createConvContent);
|
||||
Assert.True(createConvResponse.IsSuccessStatusCode);
|
||||
|
||||
string convJson = await createConvResponse.Content.ReadAsStringAsync();
|
||||
using var convDoc = System.Text.Json.JsonDocument.Parse(convJson);
|
||||
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Act - First message
|
||||
await this.SendRawResponseAsync(AgentName, "My name is Alice", conversationId, stream: false);
|
||||
|
||||
// Act - Second message in same conversation
|
||||
await this.SendRawResponseAsync(AgentName, "What is my name?", conversationId, stream: false);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, mockChatClient.CallHistory.Count);
|
||||
|
||||
// First call: should have 1 message (just the user input)
|
||||
Assert.Single(mockChatClient.CallHistory[0]);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[0][0].Role);
|
||||
|
||||
// Second call: should have 3 messages (prior user + prior assistant + new user)
|
||||
Assert.Equal(3, mockChatClient.CallHistory[1].Count);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][0].Role);
|
||||
Assert.Equal(ChatRole.Assistant, mockChatClient.CallHistory[1][1].Role);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][2].Role);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendRawResponseAsync(
|
||||
string agentName, string input, string conversationId, bool stream)
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
input,
|
||||
agent = new { name = agentName },
|
||||
conversation = conversationId,
|
||||
stream
|
||||
};
|
||||
string json = System.Text.Json.JsonSerializer.Serialize(requestBody);
|
||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
HttpResponseMessage response = await this._httpClient!.PostAsync(
|
||||
new Uri($"/{agentName}/v1/responses", UriKind.Relative), content);
|
||||
Assert.True(response.IsSuccessStatusCode, $"Response failed: {response.StatusCode}");
|
||||
|
||||
// Consume the full response body to ensure execution completes
|
||||
await response.Content.ReadAsStringAsync();
|
||||
return response;
|
||||
}
|
||||
|
||||
private ResponsesClient CreateResponseClient(string agentName)
|
||||
{
|
||||
return new ResponsesClient(
|
||||
@@ -1272,6 +1341,29 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
|
||||
return testServer.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateTestServerWithCustomClientAndConversationsAsync(string agentName, string instructions, IChatClient chatClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient);
|
||||
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}");
|
||||
builder.AddOpenAIResponses();
|
||||
builder.AddOpenAIConversations();
|
||||
|
||||
this._app = builder.Build();
|
||||
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
this._app.MapOpenAIResponses(agent);
|
||||
this._app.MapOpenAIConversations();
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
return testServer.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
|
||||
@@ -597,6 +597,86 @@ internal static class TestHelpers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock IChatClient that captures the full message list on each call.
|
||||
/// Used to verify conversation history is passed correctly.
|
||||
/// </summary>
|
||||
internal sealed class ConversationMemoryMockChatClient : IChatClient
|
||||
{
|
||||
private readonly string _responseText;
|
||||
|
||||
/// <summary>Each entry is the messages list received for that call.</summary>
|
||||
public List<List<ChatMessage>> CallHistory { get; } = [];
|
||||
|
||||
public ConversationMemoryMockChatClient(string responseText = "Test response")
|
||||
{
|
||||
this._responseText = responseText;
|
||||
}
|
||||
|
||||
public ChatClientMetadata Metadata { get; } = new("Test", new Uri("https://test.example.com"), "test-model");
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CallHistory.Add(messages.ToList());
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, this._responseText);
|
||||
ChatResponse response = new([message])
|
||||
{
|
||||
ModelId = "test-model",
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
Usage = new UsageDetails
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
}
|
||||
};
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CallHistory.Add(messages.ToList());
|
||||
await Task.Delay(1, cancellationToken);
|
||||
|
||||
string[] words = this._responseText.Split(' ');
|
||||
for (int i = 0; i < words.Length; i++)
|
||||
{
|
||||
string content = i < words.Length - 1 ? words[i] + " " : words[i];
|
||||
ChatResponseUpdate update = new()
|
||||
{
|
||||
Contents = [new TextContent(content)],
|
||||
Role = ChatRole.Assistant
|
||||
};
|
||||
|
||||
if (i == words.Length - 1)
|
||||
{
|
||||
update.Contents.Add(new UsageContent(new UsageDetails
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
}));
|
||||
}
|
||||
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType.IsInstanceOfType(this) ? this : null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom content mock implementation of IChatClient that returns custom content based on a provider function.
|
||||
/// </summary>
|
||||
|
||||
@@ -530,7 +530,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
var mockSession = new TestAgentSession();
|
||||
var sut = new Mem0Provider(this._httpClient, _ => new Mem0Provider.State(storageScope), options: new Mem0ProviderOptions
|
||||
{
|
||||
StorageInputMessageFilter = messages => messages // No filtering - store everything
|
||||
StorageInputRequestMessageFilter = messages => messages // No filtering - store everything
|
||||
});
|
||||
|
||||
var requestMessages = new List<ChatMessage>
|
||||
|
||||
@@ -108,6 +108,8 @@ public sealed class FileAgentSkillsProviderTests : IDisposable
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.StartsWith("Custom template:", result.Instructions);
|
||||
Assert.Contains("custom-prompt-skill", result.Instructions);
|
||||
Assert.Contains("Custom prompt", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+17
-5
@@ -23,6 +23,10 @@ public class ChatClientAgentOptionsTests
|
||||
Assert.Null(options.ChatOptions);
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
Assert.False(options.UseProvidedChatClientAsIs);
|
||||
Assert.True(options.ClearOnChatHistoryProviderConflict);
|
||||
Assert.True(options.WarnOnChatHistoryProviderConflict);
|
||||
Assert.True(options.ThrowOnChatHistoryProviderConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -115,8 +119,8 @@ public class ChatClientAgentOptionsTests
|
||||
const string Description = "Test description";
|
||||
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
|
||||
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null).Object;
|
||||
var mockAIContextProvider = new Mock<AIContextProvider>(null, null).Object;
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null).Object;
|
||||
var mockAIContextProvider = new Mock<AIContextProvider>(null, null, null).Object;
|
||||
|
||||
var original = new ChatClientAgentOptions()
|
||||
{
|
||||
@@ -125,7 +129,11 @@ public class ChatClientAgentOptionsTests
|
||||
ChatOptions = new() { Tools = tools },
|
||||
Id = "test-id",
|
||||
ChatHistoryProvider = mockChatHistoryProvider,
|
||||
AIContextProviders = [mockAIContextProvider]
|
||||
AIContextProviders = [mockAIContextProvider],
|
||||
UseProvidedChatClientAsIs = true,
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -138,6 +146,10 @@ public class ChatClientAgentOptionsTests
|
||||
Assert.Equal(original.Description, clone.Description);
|
||||
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
|
||||
Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
|
||||
Assert.Equal(original.UseProvidedChatClientAsIs, clone.UseProvidedChatClientAsIs);
|
||||
Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
|
||||
|
||||
// ChatOptions should be cloned, not the same reference
|
||||
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
|
||||
@@ -149,8 +161,8 @@ public class ChatClientAgentOptionsTests
|
||||
public void Clone_WithoutProvidingChatOptions_ClonesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null).Object;
|
||||
var mockAIContextProvider = new Mock<AIContextProvider>(null, null).Object;
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null).Object;
|
||||
var mockAIContextProvider = new Mock<AIContextProvider>(null, null, null).Object;
|
||||
|
||||
var original = new ChatClientAgentOptions
|
||||
{
|
||||
|
||||
@@ -488,7 +488,7 @@ public partial class ChatClientAgentTests
|
||||
})
|
||||
.ReturnsAsync(new ChatResponse(responseMessages));
|
||||
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -559,7 +559,7 @@ public partial class ChatClientAgentTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Throws(new InvalidOperationException("downstream failure"));
|
||||
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -617,7 +617,7 @@ public partial class ChatClientAgentTests
|
||||
})
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -677,7 +677,7 @@ public partial class ChatClientAgentTests
|
||||
.ReturnsAsync(new ChatResponse(responseMessages));
|
||||
|
||||
// Provider 1: adds a system message and a tool
|
||||
var mockProvider1 = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider1 = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockProvider1
|
||||
.Protected()
|
||||
@@ -696,7 +696,7 @@ public partial class ChatClientAgentTests
|
||||
|
||||
// Provider 2: adds another system message and verifies it receives accumulated context from provider 1
|
||||
AIContext? provider2ReceivedContext = null;
|
||||
var mockProvider2 = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider2 = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
|
||||
mockProvider2
|
||||
.Protected()
|
||||
@@ -784,7 +784,7 @@ public partial class ChatClientAgentTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("downstream failure"));
|
||||
|
||||
var mockProvider1 = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider1 = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockProvider1
|
||||
.Protected()
|
||||
@@ -801,7 +801,7 @@ public partial class ChatClientAgentTests
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
var mockProvider2 = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider2 = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
|
||||
mockProvider2
|
||||
.Protected()
|
||||
@@ -869,7 +869,7 @@ public partial class ChatClientAgentTests
|
||||
})
|
||||
.Returns(ToAsyncEnumerableAsync(responseUpdates));
|
||||
|
||||
var mockProvider1 = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider1 = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider1.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockProvider1
|
||||
.Protected()
|
||||
@@ -886,7 +886,7 @@ public partial class ChatClientAgentTests
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
var mockProvider2 = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider2 = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider2.SetupGet(p => p.StateKey).Returns("Provider2");
|
||||
mockProvider2
|
||||
.Protected()
|
||||
@@ -1828,7 +1828,7 @@ public partial class ChatClientAgentTests
|
||||
})
|
||||
.Returns(ToAsyncEnumerableAsync(responseUpdates));
|
||||
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -1907,7 +1907,7 @@ public partial class ChatClientAgentTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Throws(new InvalidOperationException("downstream failure"));
|
||||
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
|
||||
+8
-8
@@ -338,7 +338,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
|
||||
// Create a mock chat history provider that would normally provide messages
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null);
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
@@ -346,7 +346,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
.ReturnsAsync([new(ChatRole.User, "Message from chat history provider")]);
|
||||
|
||||
// Create a mock AI context provider that would normally provide context
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
@@ -407,7 +407,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
|
||||
// Create a mock chat history provider that would normally provide messages
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null);
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
@@ -415,7 +415,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
.ReturnsAsync([new(ChatRole.User, "Message from chat history provider")]);
|
||||
|
||||
// Create a mock AI context provider that would normally provide context
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
@@ -638,7 +638,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
List<ChatMessage> capturedMessagesAddedToProvider = [];
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null);
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
@@ -647,7 +647,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
.Returns(new ValueTask());
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
@@ -702,7 +702,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
.Returns(ToAsyncEnumerableAsync(Array.Empty<ChatResponseUpdate>()));
|
||||
|
||||
List<ChatMessage> capturedMessagesAddedToProvider = [];
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null);
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKey).Returns("ChatHistoryProvider");
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
@@ -711,7 +711,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
.Returns(new ValueTask());
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null);
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKey).Returns("Provider1");
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
|
||||
+122
-4
@@ -185,7 +185,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null);
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -240,7 +240,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Throws(new InvalidOperationException("Test Error"));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null);
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -291,6 +291,124 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
Assert.Equal("Only ConversationId or ChatHistoryProvider may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a ChatHistoryProvider configured.", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync clears the ChatHistoryProvider when ThrowOnChatHistoryProviderConflict is false
|
||||
/// and ClearOnChatHistoryProviderConflict is true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ClearsChatHistoryProvider_WhenThrowDisabledAndClearEnabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(),
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
ClearOnChatHistoryProviderConflict = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.ChatHistoryProvider);
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync does not throw and does not clear the ChatHistoryProvider when both
|
||||
/// ThrowOnChatHistoryProviderConflict and ClearOnChatHistoryProviderConflict are false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_KeepsChatHistoryProvider_WhenThrowAndClearDisabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
var chatHistoryProvider = new InMemoryChatHistoryProvider();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Same(chatHistoryProvider, agent.ChatHistoryProvider);
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync still throws when ThrowOnChatHistoryProviderConflict is true
|
||||
/// even if ClearOnChatHistoryProviderConflict is also true (throw takes precedence).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Throws_WhenThrowEnabledRegardlessOfClearSettingAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(),
|
||||
ThrowOnChatHistoryProviderConflict = true,
|
||||
ClearOnChatHistoryProviderConflict = true,
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync does not throw when no ChatHistoryProvider is configured on options,
|
||||
/// even if the service returns a conversation id (default InMemoryChatHistoryProvider is used but not from options).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotThrow_WhenNoChatHistoryProviderInOptionsAndConversationIdReturnedAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert - no exception, session gets the conversation id
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatHistoryProvider Override Tests
|
||||
@@ -311,7 +429,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
// Arrange a chat history provider to override the factory provided one.
|
||||
Mock<ChatHistoryProvider> mockOverrideChatHistoryProvider = new(null, null);
|
||||
Mock<ChatHistoryProvider> mockOverrideChatHistoryProvider = new(null, null, null);
|
||||
mockOverrideChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
@@ -324,7 +442,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
|
||||
// Arrange a chat history provider to provide to the agent at construction time.
|
||||
// This one shouldn't be used since it is being overridden.
|
||||
Mock<ChatHistoryProvider> mockAgentOptionsChatHistoryProvider = new(null, null);
|
||||
Mock<ChatHistoryProvider> mockAgentOptionsChatHistoryProvider = new(null, null, null);
|
||||
mockAgentOptionsChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user